Merge pull request #440 from ripcurld0/search_format

Add --format to docker-search
This commit is contained in:
Daniel Nephin 2017-08-22 19:10:53 -04:00 committed by GitHub
commit 05308fcec7
4 changed files with 452 additions and 29 deletions

View File

@ -0,0 +1,104 @@
package formatter
import (
"strconv"
"strings"
registry "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/pkg/stringutils"
)
const (
defaultSearchTableFormat = "table {{.Name}}\t{{.Description}}\t{{.StarCount}}\t{{.IsOfficial}}\t{{.IsAutomated}}"
starsHeader = "STARS"
officialHeader = "OFFICIAL"
automatedHeader = "AUTOMATED"
)
// NewSearchFormat returns a Format for rendering using a network Context
func NewSearchFormat(source string) Format {
switch source {
case "":
return defaultSearchTableFormat
case TableFormatKey:
return defaultSearchTableFormat
}
return Format(source)
}
// SearchWrite writes the context
func SearchWrite(ctx Context, results []registry.SearchResult, auto bool, stars int) error {
render := func(format func(subContext subContext) error) error {
for _, result := range results {
// --automated and -s, --stars are deprecated since Docker 1.12
if (auto && !result.IsAutomated) || (stars > result.StarCount) {
continue
}
searchCtx := &searchContext{trunc: ctx.Trunc, s: result}
if err := format(searchCtx); err != nil {
return err
}
}
return nil
}
searchCtx := searchContext{}
searchCtx.header = map[string]string{
"Name": nameHeader,
"Description": descriptionHeader,
"StarCount": starsHeader,
"IsOfficial": officialHeader,
"IsAutomated": automatedHeader,
}
return ctx.Write(&searchCtx, render)
}
type searchContext struct {
HeaderContext
trunc bool
json bool
s registry.SearchResult
}
func (c *searchContext) MarshalJSON() ([]byte, error) {
c.json = true
return marshalJSON(c)
}
func (c *searchContext) Name() string {
return c.s.Name
}
func (c *searchContext) Description() string {
desc := strings.Replace(c.s.Description, "\n", " ", -1)
desc = strings.Replace(desc, "\r", " ", -1)
if c.trunc {
desc = stringutils.Ellipsis(desc, 45)
}
return desc
}
func (c *searchContext) StarCount() string {
return strconv.Itoa(c.s.StarCount)
}
func (c *searchContext) formatBool(value bool) string {
switch {
case value && c.json:
return "true"
case value:
return "[OK]"
case c.json:
return "false"
default:
return ""
}
}
func (c *searchContext) IsOfficial() string {
return c.formatBool(c.s.IsOfficial)
}
func (c *searchContext) IsAutomated() string {
return c.formatBool(c.s.IsAutomated)
}

View File

@ -0,0 +1,284 @@
package formatter
import (
"bytes"
"encoding/json"
"strings"
"testing"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/pkg/stringutils"
"github.com/stretchr/testify/assert"
)
func TestSearchContext(t *testing.T) {
name := "nginx"
starCount := 5000
var ctx searchContext
cases := []struct {
searchCtx searchContext
expValue string
call func() string
}{
{searchContext{
s: registrytypes.SearchResult{Name: name},
}, name, ctx.Name},
{searchContext{
s: registrytypes.SearchResult{StarCount: starCount},
}, "5000", ctx.StarCount},
{searchContext{
s: registrytypes.SearchResult{IsOfficial: true},
}, "[OK]", ctx.IsOfficial},
{searchContext{
s: registrytypes.SearchResult{IsOfficial: false},
}, "", ctx.IsOfficial},
{searchContext{
s: registrytypes.SearchResult{IsAutomated: true},
}, "[OK]", ctx.IsAutomated},
{searchContext{
s: registrytypes.SearchResult{IsAutomated: false},
}, "", ctx.IsAutomated},
}
for _, c := range cases {
ctx = c.searchCtx
v := c.call()
if strings.Contains(v, ",") {
compareMultipleValues(t, v, c.expValue)
} else if v != c.expValue {
t.Fatalf("Expected %s, was %s\n", c.expValue, v)
}
}
}
func TestSearchContextDescription(t *testing.T) {
shortDescription := "Official build of Nginx."
longDescription := "Automated Nginx reverse proxy for docker containers"
descriptionWReturns := "Automated\nNginx reverse\rproxy\rfor docker\ncontainers"
var ctx searchContext
cases := []struct {
searchCtx searchContext
expValue string
call func() string
}{
{searchContext{
s: registrytypes.SearchResult{Description: shortDescription},
trunc: true,
}, shortDescription, ctx.Description},
{searchContext{
s: registrytypes.SearchResult{Description: shortDescription},
trunc: false,
}, shortDescription, ctx.Description},
{searchContext{
s: registrytypes.SearchResult{Description: longDescription},
trunc: false,
}, longDescription, ctx.Description},
{searchContext{
s: registrytypes.SearchResult{Description: longDescription},
trunc: true,
}, stringutils.Ellipsis(longDescription, 45), ctx.Description},
{searchContext{
s: registrytypes.SearchResult{Description: descriptionWReturns},
trunc: false,
}, longDescription, ctx.Description},
{searchContext{
s: registrytypes.SearchResult{Description: descriptionWReturns},
trunc: true,
}, stringutils.Ellipsis(longDescription, 45), ctx.Description},
}
for _, c := range cases {
ctx = c.searchCtx
v := c.call()
if strings.Contains(v, ",") {
compareMultipleValues(t, v, c.expValue)
} else if v != c.expValue {
t.Fatalf("Expected %s, was %s\n", c.expValue, v)
}
}
}
func TestSearchContextWrite(t *testing.T) {
cases := []struct {
context Context
expected string
}{
// Errors
{
Context{Format: "{{InvalidFunction}}"},
`Template parsing error: template: :1: function "InvalidFunction" not defined
`,
},
{
Context{Format: "{{nil}}"},
`Template parsing error: template: :1:2: executing "" at <nil>: nil is not a command
`,
},
// Table format
{
Context{Format: NewSearchFormat("table")},
`NAME DESCRIPTION STARS OFFICIAL AUTOMATED
result1 Official build 5000 [OK]
result2 Not official 5 [OK]
`,
},
{
Context{Format: NewSearchFormat("table {{.Name}}")},
`NAME
result1
result2
`,
},
// Custom Format
{
Context{Format: NewSearchFormat("{{.Name}}")},
`result1
result2
`,
},
// Custom Format with CreatedAt
{
Context{Format: NewSearchFormat("{{.Name}} {{.StarCount}}")},
`result1 5000
result2 5
`,
},
}
for _, testcase := range cases {
results := []registrytypes.SearchResult{
{Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false},
{Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true},
}
out := bytes.NewBufferString("")
testcase.context.Output = out
err := SearchWrite(testcase.context, results, false, 0)
if err != nil {
assert.Error(t, err, testcase.expected)
} else {
assert.Equal(t, out.String(), testcase.expected)
}
}
}
func TestSearchContextWriteAutomated(t *testing.T) {
cases := []struct {
context Context
expected string
}{
// Table format
{
Context{Format: NewSearchFormat("table")},
`NAME DESCRIPTION STARS OFFICIAL AUTOMATED
result2 Not official 5 [OK]
`,
},
{
Context{Format: NewSearchFormat("table {{.Name}}")},
`NAME
result2
`,
},
}
for _, testcase := range cases {
results := []registrytypes.SearchResult{
{Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false},
{Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true},
}
out := bytes.NewBufferString("")
testcase.context.Output = out
err := SearchWrite(testcase.context, results, true, 0)
if err != nil {
assert.Error(t, err, testcase.expected)
} else {
assert.Equal(t, out.String(), testcase.expected)
}
}
}
func TestSearchContextWriteStars(t *testing.T) {
cases := []struct {
context Context
expected string
}{
// Table format
{
Context{Format: NewSearchFormat("table")},
`NAME DESCRIPTION STARS OFFICIAL AUTOMATED
result1 Official build 5000 [OK]
`,
},
{
Context{Format: NewSearchFormat("table {{.Name}}")},
`NAME
result1
`,
},
}
for _, testcase := range cases {
results := []registrytypes.SearchResult{
{Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false},
{Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true},
}
out := bytes.NewBufferString("")
testcase.context.Output = out
err := SearchWrite(testcase.context, results, false, 6)
if err != nil {
assert.Error(t, err, testcase.expected)
} else {
assert.Equal(t, out.String(), testcase.expected)
}
}
}
func TestSearchContextWriteJSON(t *testing.T) {
results := []registrytypes.SearchResult{
{Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false},
{Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true},
}
expectedJSONs := []map[string]interface{}{
{"Name": "result1", "Description": "Official build", "StarCount": "5000", "IsOfficial": "true", "IsAutomated": "false"},
{"Name": "result2", "Description": "Not official", "StarCount": "5", "IsOfficial": "false", "IsAutomated": "true"},
}
out := bytes.NewBufferString("")
err := SearchWrite(Context{Format: "{{json .}}", Output: out}, results, false, 0)
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
t.Logf("Output: line %d: %s", i, line)
var m map[string]interface{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatal(err)
}
assert.Equal(t, m, expectedJSONs[i])
}
}
func TestSearchContextWriteJSONField(t *testing.T) {
results := []registrytypes.SearchResult{
{Name: "result1", Description: "Official build", StarCount: 5000, IsOfficial: true, IsAutomated: false},
{Name: "result2", Description: "Not official", StarCount: 5, IsOfficial: false, IsAutomated: true},
}
out := bytes.NewBufferString("")
err := SearchWrite(Context{Format: "{{json .Name}}", Output: out}, results, false, 0)
if err != nil {
t.Fatal(err)
}
for i, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
t.Logf("Output: line %d: %s", i, line)
var s string
if err := json.Unmarshal([]byte(line), &s); err != nil {
t.Fatal(err)
}
assert.Equal(t, s, results[i].Name)
}
}

View File

@ -1,23 +1,21 @@
package registry
import (
"fmt"
"sort"
"strings"
"text/tabwriter"
"github.com/docker/cli/cli"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/formatter"
"github.com/docker/cli/opts"
"github.com/docker/docker/api/types"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/pkg/stringutils"
"github.com/docker/docker/registry"
"github.com/spf13/cobra"
"golang.org/x/net/context"
)
type searchOptions struct {
format string
term string
noTrunc bool
limit int
@ -47,6 +45,7 @@ func NewSearchCommand(dockerCli command.Cli) *cobra.Command {
flags.BoolVar(&options.noTrunc, "no-trunc", false, "Don't truncate output")
flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided")
flags.IntVar(&options.limit, "limit", registry.DefaultSearchLimit, "Max number of search results")
flags.StringVar(&options.format, "format", "", "Pretty-print search using a Go template")
flags.BoolVar(&options.automated, "automated", false, "Only show automated builds")
flags.UintVarP(&options.stars, "stars", "s", 0, "Only displays with at least x stars")
@ -89,32 +88,12 @@ func runSearch(dockerCli command.Cli, options searchOptions) error {
results := searchResultsByStars(unorderedResults)
sort.Sort(results)
w := tabwriter.NewWriter(dockerCli.Out(), 10, 1, 3, ' ', 0)
fmt.Fprintf(w, "NAME\tDESCRIPTION\tSTARS\tOFFICIAL\tAUTOMATED\n")
for _, res := range results {
// --automated and -s, --stars are deprecated since Docker 1.12
if (options.automated && !res.IsAutomated) || (int(options.stars) > res.StarCount) {
continue
searchCtx := formatter.Context{
Output: dockerCli.Out(),
Format: formatter.NewSearchFormat(options.format),
Trunc: !options.noTrunc,
}
desc := strings.Replace(res.Description, "\n", " ", -1)
desc = strings.Replace(desc, "\r", " ", -1)
if !options.noTrunc {
desc = stringutils.Ellipsis(desc, 45)
}
fmt.Fprintf(w, "%s\t%s\t%d\t", res.Name, desc, res.StarCount)
if res.IsOfficial {
fmt.Fprint(w, "[OK]")
}
fmt.Fprint(w, "\t")
if res.IsAutomated {
fmt.Fprint(w, "[OK]")
}
fmt.Fprint(w, "\n")
}
w.Flush()
return nil
return formatter.SearchWrite(searchCtx, results, options.automated, int(options.stars))
}
// searchResultsByStars sorts search results in descending order by number of stars.

View File

@ -25,6 +25,7 @@ Options:
- is-automated=(true|false)
- is-official=(true|false)
- stars=<number> - image has at least 'number' stars
--format string Pretty-print images using a Go template
--help Print usage
--limit int Max number of search results (default 25)
--no-trunc Don't truncate output
@ -144,3 +145,58 @@ NAME DESCRIPTION STARS O
progrium/busybox 50 [OK]
radial/busyboxplus Full-chain, Internet enabled, busybox made... 8 [OK]
```
### Format the output
The formatting option (`--format`) pretty-prints search output
using a Go template.
Valid placeholders for the Go template are:
| Placeholder | Description |
| -------------- | --------------------------------- |
| `.Name` | Image Name |
| `.Description` | Image description |
| `.StarCount` | Number of stars for the image |
| `.IsOfficial` | "OK" if image is official |
| `.IsAutomated` | "OK" if image build was automated |
When you use the `--format` option, the `search` command will
output the data exactly as the template declares. If you use the
`table` directive, column headers are included as well.
The following example uses a template without headers and outputs the
`Name` and `StarCount` entries separated by a colon for all images:
```bash
{% raw %}
$ docker search --format "{{.Name}}: {{.StarCount}}" nginx
nginx: 5441
jwilder/nginx-proxy: 953
richarvey/nginx-php-fpm: 353
million12/nginx-php: 75
webdevops/php-nginx: 70
h3nrik/nginx-ldap: 35
bitnami/nginx: 23
evild/alpine-nginx: 14
million12/nginx: 9
maxexcloo/nginx: 7
{% endraw %}
```
This example outputs a table format:
```bash
{% raw %}
$ docker search --format "table {{.Name}}\t{{.IsAutomated}}\t{{.IsOfficial}}" nginx
NAME AUTOMATED OFFICIAL
nginx [OK]
jwilder/nginx-proxy [OK]
richarvey/nginx-php-fpm [OK]
jrcs/letsencrypt-nginx-proxy-companion [OK]
million12/nginx-php [OK]
webdevops/php-nginx [OK]
{% endraw %}
```