Bläddra i källkod

Add --format support to images command

- rename `api/client/ps` to `api/client/formatter`
- add a a image formatter

Signed-off-by: Vincent Demeester <vincent@sbr.pm>
Vincent Demeester 9 år sedan
förälder
incheckning
34a3c3cacf

+ 7 - 1
api/client/cli.go

@@ -68,11 +68,17 @@ func (cli *DockerCli) CheckTtyInput(attachStdin, ttyMode bool) error {
 }
 
 // PsFormat returns the format string specified in the configuration.
-// String contains columns and format specification, for example {{ID}\t{{Name}}.
+// String contains columns and format specification, for example {{ID}}\t{{Name}}.
 func (cli *DockerCli) PsFormat() string {
 	return cli.configFile.PsFormat
 }
 
+// ImagesFormat returns the format string specified in the configuration.
+// String contains columns and format specification, for example {{ID}}\t{{Name}}.
+func (cli *DockerCli) ImagesFormat() string {
+	return cli.configFile.ImagesFormat
+}
+
 // NewDockerCli returns a DockerCli instance with IO output and error streams set by in, out and err.
 // The key file, protocol (i.e. unix) and address are passed in as strings, along with the tls.Config. If the tls.Config
 // is set the client scheme will be set to https.

+ 79 - 17
api/client/ps/custom.go → api/client/formatter/custom.go

@@ -1,4 +1,4 @@
-package ps
+package formatter
 
 import (
 	"fmt"
@@ -16,26 +16,31 @@ import (
 const (
 	tableKey = "table"
 
-	idHeader         = "CONTAINER ID"
-	imageHeader      = "IMAGE"
-	namesHeader      = "NAMES"
-	commandHeader    = "COMMAND"
-	createdAtHeader  = "CREATED AT"
-	runningForHeader = "CREATED"
-	statusHeader     = "STATUS"
-	portsHeader      = "PORTS"
-	sizeHeader       = "SIZE"
-	labelsHeader     = "LABELS"
+	containerIDHeader  = "CONTAINER ID"
+	imageHeader        = "IMAGE"
+	namesHeader        = "NAMES"
+	commandHeader      = "COMMAND"
+	createdSinceHeader = "CREATED"
+	createdAtHeader    = "CREATED AT"
+	runningForHeader   = "CREATED"
+	statusHeader       = "STATUS"
+	portsHeader        = "PORTS"
+	sizeHeader         = "SIZE"
+	labelsHeader       = "LABELS"
+	imageIDHeader      = "IMAGE ID"
+	repositoryHeader   = "REPOSITORY"
+	tagHeader          = "TAG"
+	digestHeader       = "DIGEST"
 )
 
 type containerContext struct {
-	trunc  bool
-	header []string
-	c      types.Container
+	baseSubContext
+	trunc bool
+	c     types.Container
 }
 
 func (c *containerContext) ID() string {
-	c.addHeader(idHeader)
+	c.addHeader(containerIDHeader)
 	if c.trunc {
 		return stringid.TruncateID(c.c.ID)
 	}
@@ -137,14 +142,71 @@ func (c *containerContext) Label(name string) string {
 	return c.c.Labels[name]
 }
 
-func (c *containerContext) fullHeader() string {
+type imageContext struct {
+	baseSubContext
+	trunc  bool
+	i      types.Image
+	repo   string
+	tag    string
+	digest string
+}
+
+func (c *imageContext) ID() string {
+	c.addHeader(imageIDHeader)
+	if c.trunc {
+		return stringid.TruncateID(c.i.ID)
+	}
+	return c.i.ID
+}
+
+func (c *imageContext) Repository() string {
+	c.addHeader(repositoryHeader)
+	return c.repo
+}
+
+func (c *imageContext) Tag() string {
+	c.addHeader(tagHeader)
+	return c.tag
+}
+
+func (c *imageContext) Digest() string {
+	c.addHeader(digestHeader)
+	return c.digest
+}
+
+func (c *imageContext) CreatedSince() string {
+	c.addHeader(createdSinceHeader)
+	createdAt := time.Unix(int64(c.i.Created), 0)
+	return units.HumanDuration(time.Now().UTC().Sub(createdAt))
+}
+
+func (c *imageContext) CreatedAt() string {
+	c.addHeader(createdAtHeader)
+	return time.Unix(int64(c.i.Created), 0).String()
+}
+
+func (c *imageContext) Size() string {
+	c.addHeader(sizeHeader)
+	return units.HumanSize(float64(c.i.Size))
+}
+
+type subContext interface {
+	fullHeader() string
+	addHeader(header string)
+}
+
+type baseSubContext struct {
+	header []string
+}
+
+func (c *baseSubContext) fullHeader() string {
 	if c.header == nil {
 		return ""
 	}
 	return strings.Join(c.header, "\t")
 }
 
-func (c *containerContext) addHeader(header string) {
+func (c *baseSubContext) addHeader(header string) {
 	if c.header == nil {
 		c.header = []string{}
 	}

+ 87 - 21
api/client/ps/custom_test.go → api/client/formatter/custom_test.go

@@ -1,4 +1,4 @@
-package ps
+package formatter
 
 import (
 	"reflect"
@@ -22,8 +22,8 @@ func TestContainerPsContext(t *testing.T) {
 		expHeader string
 		call      func() string
 	}{
-		{types.Container{ID: containerID}, true, stringid.TruncateID(containerID), idHeader, ctx.ID},
-		{types.Container{ID: containerID}, false, containerID, idHeader, ctx.ID},
+		{types.Container{ID: containerID}, true, stringid.TruncateID(containerID), containerIDHeader, ctx.ID},
+		{types.Container{ID: containerID}, false, containerID, containerIDHeader, ctx.ID},
 		{types.Container{Names: []string{"/foobar_baz"}}, true, "foobar_baz", namesHeader, ctx.Names},
 		{types.Container{Image: "ubuntu"}, true, "ubuntu", imageHeader, ctx.Image},
 		{types.Container{Image: "verylongimagename"}, true, "verylongimagename", imageHeader, ctx.Image},
@@ -62,24 +62,7 @@ func TestContainerPsContext(t *testing.T) {
 		ctx = containerContext{c: c.container, trunc: c.trunc}
 		v := c.call()
 		if strings.Contains(v, ",") {
-			// comma-separated values means probably a map input, which won't
-			// be guaranteed to have the same order as our expected value
-			// We'll create maps and use reflect.DeepEquals to check instead:
-			entriesMap := make(map[string]string)
-			expMap := make(map[string]string)
-			entries := strings.Split(v, ",")
-			expectedEntries := strings.Split(c.expValue, ",")
-			for _, entry := range entries {
-				keyval := strings.Split(entry, "=")
-				entriesMap[keyval[0]] = keyval[1]
-			}
-			for _, expected := range expectedEntries {
-				keyval := strings.Split(expected, "=")
-				expMap[keyval[0]] = keyval[1]
-			}
-			if !reflect.DeepEqual(expMap, entriesMap) {
-				t.Fatalf("Expected entries: %v, got: %v", c.expValue, v)
-			}
+			compareMultipleValues(t, v, c.expValue)
 		} else if v != c.expValue {
 			t.Fatalf("Expected %s, was %s\n", c.expValue, v)
 		}
@@ -124,3 +107,86 @@ func TestContainerPsContext(t *testing.T) {
 	}
 
 }
+
+func TestImagesContext(t *testing.T) {
+	imageID := stringid.GenerateRandomID()
+	unix := time.Now().Unix()
+
+	var ctx imageContext
+	cases := []struct {
+		imageCtx  imageContext
+		expValue  string
+		expHeader string
+		call      func() string
+	}{
+		{imageContext{
+			i:     types.Image{ID: imageID},
+			trunc: true,
+		}, stringid.TruncateID(imageID), imageIDHeader, ctx.ID},
+		{imageContext{
+			i:     types.Image{ID: imageID},
+			trunc: false,
+		}, imageID, imageIDHeader, ctx.ID},
+		{imageContext{
+			i:     types.Image{Size: 10},
+			trunc: true,
+		}, "10 B", sizeHeader, ctx.Size},
+		{imageContext{
+			i:     types.Image{Created: unix},
+			trunc: true,
+		}, time.Unix(unix, 0).String(), createdAtHeader, ctx.CreatedAt},
+		// FIXME
+		// {imageContext{
+		// 	i:     types.Image{Created: unix},
+		// 	trunc: true,
+		// }, units.HumanDuration(time.Unix(unix, 0)), createdSinceHeader, ctx.CreatedSince},
+		{imageContext{
+			i:    types.Image{},
+			repo: "busybox",
+		}, "busybox", repositoryHeader, ctx.Repository},
+		{imageContext{
+			i:   types.Image{},
+			tag: "latest",
+		}, "latest", tagHeader, ctx.Tag},
+		{imageContext{
+			i:      types.Image{},
+			digest: "sha256:d149ab53f8718e987c3a3024bb8aa0e2caadf6c0328f1d9d850b2a2a67f2819a",
+		}, "sha256:d149ab53f8718e987c3a3024bb8aa0e2caadf6c0328f1d9d850b2a2a67f2819a", digestHeader, ctx.Digest},
+	}
+
+	for _, c := range cases {
+		ctx = c.imageCtx
+		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)
+		}
+
+		h := ctx.fullHeader()
+		if h != c.expHeader {
+			t.Fatalf("Expected %s, was %s\n", c.expHeader, h)
+		}
+	}
+}
+
+func compareMultipleValues(t *testing.T, value, expected string) {
+	// comma-separated values means probably a map input, which won't
+	// be guaranteed to have the same order as our expected value
+	// We'll create maps and use reflect.DeepEquals to check instead:
+	entriesMap := make(map[string]string)
+	expMap := make(map[string]string)
+	entries := strings.Split(value, ",")
+	expectedEntries := strings.Split(expected, ",")
+	for _, entry := range entries {
+		keyval := strings.Split(entry, "=")
+		entriesMap[keyval[0]] = keyval[1]
+	}
+	for _, expected := range expectedEntries {
+		keyval := strings.Split(expected, "=")
+		expMap[keyval[0]] = keyval[1]
+	}
+	if !reflect.DeepEqual(expMap, entriesMap) {
+		t.Fatalf("Expected entries: %v, got: %v", expected, value)
+	}
+}

+ 254 - 0
api/client/formatter/formatter.go

@@ -0,0 +1,254 @@
+package formatter
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"strings"
+	"text/tabwriter"
+	"text/template"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/reference"
+)
+
+const (
+	tableFormatKey = "table"
+	rawFormatKey   = "raw"
+
+	defaultContainerTableFormat       = "table {{.ID}}\t{{.Image}}\t{{.Command}}\t{{.RunningFor}} ago\t{{.Status}}\t{{.Ports}}\t{{.Names}}"
+	defaultImageTableFormat           = "table {{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}} ago\t{{.Size}}"
+	defaultImageTableFormatWithDigest = "table {{.Repository}}\t{{.Tag}}\t{{.Digest}}\t{{.ID}}\t{{.CreatedSince}} ago\t{{.Size}}"
+	defaultQuietFormat                = "{{.ID}}"
+)
+
+// Context contains information required by the formatter to print the output as desired.
+type Context struct {
+	// Output is the output stream to which the formatted string is written.
+	Output io.Writer
+	// Format is used to choose raw, table or custom format for the output.
+	Format string
+	// Quiet when set to true will simply print minimal information.
+	Quiet bool
+	// Trunc when set to true will truncate the output of certain fields such as Container ID.
+	Trunc bool
+
+	// internal element
+	table       bool
+	finalFormat string
+	header      string
+	buffer      *bytes.Buffer
+}
+
+func (c *Context) preformat() {
+	c.finalFormat = c.Format
+
+	if strings.HasPrefix(c.Format, tableKey) {
+		c.table = true
+		c.finalFormat = c.finalFormat[len(tableKey):]
+	}
+
+	c.finalFormat = strings.Trim(c.finalFormat, " ")
+	r := strings.NewReplacer(`\t`, "\t", `\n`, "\n")
+	c.finalFormat = r.Replace(c.finalFormat)
+}
+
+func (c *Context) parseFormat() (*template.Template, error) {
+	tmpl, err := template.New("").Parse(c.finalFormat)
+	if err != nil {
+		c.buffer.WriteString(fmt.Sprintf("Template parsing error: %v\n", err))
+		c.buffer.WriteTo(c.Output)
+	}
+	return tmpl, err
+}
+
+func (c *Context) postformat(tmpl *template.Template, subContext subContext) {
+	if c.table {
+		if len(c.header) == 0 {
+			// if we still don't have a header, we didn't have any containers so we need to fake it to get the right headers from the template
+			tmpl.Execute(bytes.NewBufferString(""), subContext)
+			c.header = subContext.fullHeader()
+		}
+
+		t := tabwriter.NewWriter(c.Output, 20, 1, 3, ' ', 0)
+		t.Write([]byte(c.header))
+		t.Write([]byte("\n"))
+		c.buffer.WriteTo(t)
+		t.Flush()
+	} else {
+		c.buffer.WriteTo(c.Output)
+	}
+}
+
+func (c *Context) contextFormat(tmpl *template.Template, subContext subContext) error {
+	if err := tmpl.Execute(c.buffer, subContext); err != nil {
+		c.buffer = bytes.NewBufferString(fmt.Sprintf("Template parsing error: %v\n", err))
+		c.buffer.WriteTo(c.Output)
+		return err
+	}
+	if c.table && len(c.header) == 0 {
+		c.header = subContext.fullHeader()
+	}
+	c.buffer.WriteString("\n")
+	return nil
+}
+
+// ContainerContext contains container specific information required by the formater, encapsulate a Context struct.
+type ContainerContext struct {
+	Context
+	// Size when set to true will display the size of the output.
+	Size bool
+	// Containers
+	Containers []types.Container
+}
+
+// ImageContext contains image specific information required by the formater, encapsulate a Context struct.
+type ImageContext struct {
+	Context
+	Digest bool
+	// Images
+	Images []types.Image
+}
+
+func (ctx ContainerContext) Write() {
+	switch ctx.Format {
+	case tableFormatKey:
+		ctx.Format = defaultContainerTableFormat
+		if ctx.Quiet {
+			ctx.Format = defaultQuietFormat
+		}
+	case rawFormatKey:
+		if ctx.Quiet {
+			ctx.Format = `container_id: {{.ID}}`
+		} else {
+			ctx.Format = `container_id: {{.ID}}
+image: {{.Image}}
+command: {{.Command}}
+created_at: {{.CreatedAt}}
+status: {{.Status}}
+names: {{.Names}}
+labels: {{.Labels}}
+ports: {{.Ports}}
+`
+			if ctx.Size {
+				ctx.Format += `size: {{.Size}}
+`
+			}
+		}
+	}
+
+	ctx.buffer = bytes.NewBufferString("")
+	ctx.preformat()
+	if ctx.table && ctx.Size {
+		ctx.finalFormat += "\t{{.Size}}"
+	}
+
+	tmpl, err := ctx.parseFormat()
+	if err != nil {
+		return
+	}
+
+	for _, container := range ctx.Containers {
+		containerCtx := &containerContext{
+			trunc: ctx.Trunc,
+			c:     container,
+		}
+		err = ctx.contextFormat(tmpl, containerCtx)
+		if err != nil {
+			return
+		}
+	}
+
+	ctx.postformat(tmpl, &containerContext{})
+}
+
+func (ctx ImageContext) Write() {
+	switch ctx.Format {
+	case tableFormatKey:
+		ctx.Format = defaultImageTableFormat
+		if ctx.Digest {
+			ctx.Format = defaultImageTableFormatWithDigest
+		}
+		if ctx.Quiet {
+			ctx.Format = defaultQuietFormat
+		}
+	case rawFormatKey:
+		if ctx.Quiet {
+			ctx.Format = `image_id: {{.ID}}`
+		} else {
+			if ctx.Digest {
+				ctx.Format = `repository: {{ .Repository }}
+tag: {{.Tag}}
+digest: {{.Digest}}
+image_id: {{.ID}}
+created_at: {{.CreatedAt}}
+virtual_size: {{.Size}}
+`
+			} else {
+				ctx.Format = `repository: {{ .Repository }}
+tag: {{.Tag}}
+image_id: {{.ID}}
+created_at: {{.CreatedAt}}
+virtual_size: {{.Size}}
+`
+			}
+		}
+	}
+
+	ctx.buffer = bytes.NewBufferString("")
+	ctx.preformat()
+	if ctx.table && ctx.Digest && !strings.Contains(ctx.Format, "{{.Digest}}") {
+		ctx.finalFormat += "\t{{.Digest}}"
+	}
+
+	tmpl, err := ctx.parseFormat()
+	if err != nil {
+		return
+	}
+
+	for _, image := range ctx.Images {
+
+		repoTags := image.RepoTags
+		repoDigests := image.RepoDigests
+
+		if len(repoTags) == 1 && repoTags[0] == "<none>:<none>" && len(repoDigests) == 1 && repoDigests[0] == "<none>@<none>" {
+			// dangling image - clear out either repoTags or repoDigests so we only show it once below
+			repoDigests = []string{}
+		}
+		// combine the tags and digests lists
+		tagsAndDigests := append(repoTags, repoDigests...)
+		for _, repoAndRef := range tagsAndDigests {
+			repo := "<none>"
+			tag := "<none>"
+			digest := "<none>"
+
+			if !strings.HasPrefix(repoAndRef, "<none>") {
+				ref, err := reference.ParseNamed(repoAndRef)
+				if err != nil {
+					continue
+				}
+				repo = ref.Name()
+
+				switch x := ref.(type) {
+				case reference.Canonical:
+					digest = x.Digest().String()
+				case reference.NamedTagged:
+					tag = x.Tag()
+				}
+			}
+			imageCtx := &imageContext{
+				trunc:  ctx.Trunc,
+				i:      image,
+				repo:   repo,
+				tag:    tag,
+				digest: digest,
+			}
+			err = ctx.contextFormat(tmpl, imageCtx)
+			if err != nil {
+				return
+			}
+		}
+	}
+
+	ctx.postformat(tmpl, &imageContext{})
+}

+ 535 - 0
api/client/formatter/formatter_test.go

@@ -0,0 +1,535 @@
+package formatter
+
+import (
+	"bytes"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/docker/docker/api/types"
+)
+
+func TestContainerContextWrite(t *testing.T) {
+	unixTime := time.Now().AddDate(0, 0, -1).Unix()
+	expectedTime := time.Unix(unixTime, 0).String()
+
+	contexts := []struct {
+		context  ContainerContext
+		expected string
+	}{
+		// Errors
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "{{InvalidFunction}}",
+				},
+			},
+			`Template parsing error: template: :1: function "InvalidFunction" not defined
+`,
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "{{nil}}",
+				},
+			},
+			`Template parsing error: template: :1:2: executing "" at <nil>: nil is not a command
+`,
+		},
+		// Table Format
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "table",
+				},
+			},
+			`CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
+containerID1        ubuntu              ""                  24 hours ago                                                foobar_baz
+containerID2        ubuntu              ""                  24 hours ago                                                foobar_bar
+`,
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "table {{.Image}}",
+				},
+			},
+			"IMAGE\nubuntu\nubuntu\n",
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "table {{.Image}}",
+				},
+				Size: true,
+			},
+			"IMAGE               SIZE\nubuntu              0 B\nubuntu              0 B\n",
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "table {{.Image}}",
+					Quiet:  true,
+				},
+			},
+			"IMAGE\nubuntu\nubuntu\n",
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "table",
+					Quiet:  true,
+				},
+			},
+			"containerID1\ncontainerID2\n",
+		},
+		// Raw Format
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "raw",
+				},
+			},
+			fmt.Sprintf(`container_id: containerID1
+image: ubuntu
+command: ""
+created_at: %s
+status: 
+names: foobar_baz
+labels: 
+ports: 
+
+container_id: containerID2
+image: ubuntu
+command: ""
+created_at: %s
+status: 
+names: foobar_bar
+labels: 
+ports: 
+
+`, expectedTime, expectedTime),
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "raw",
+				},
+				Size: true,
+			},
+			fmt.Sprintf(`container_id: containerID1
+image: ubuntu
+command: ""
+created_at: %s
+status: 
+names: foobar_baz
+labels: 
+ports: 
+size: 0 B
+
+container_id: containerID2
+image: ubuntu
+command: ""
+created_at: %s
+status: 
+names: foobar_bar
+labels: 
+ports: 
+size: 0 B
+
+`, expectedTime, expectedTime),
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "raw",
+					Quiet:  true,
+				},
+			},
+			"container_id: containerID1\ncontainer_id: containerID2\n",
+		},
+		// Custom Format
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "{{.Image}}",
+				},
+			},
+			"ubuntu\nubuntu\n",
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "{{.Image}}",
+				},
+				Size: true,
+			},
+			"ubuntu\nubuntu\n",
+		},
+	}
+
+	for _, context := range contexts {
+		containers := []types.Container{
+			{ID: "containerID1", Names: []string{"/foobar_baz"}, Image: "ubuntu", Created: unixTime},
+			{ID: "containerID2", Names: []string{"/foobar_bar"}, Image: "ubuntu", Created: unixTime},
+		}
+		out := bytes.NewBufferString("")
+		context.context.Output = out
+		context.context.Containers = containers
+		context.context.Write()
+		actual := out.String()
+		if actual != context.expected {
+			t.Fatalf("Expected \n%s, got \n%s", context.expected, actual)
+		}
+		// Clean buffer
+		out.Reset()
+	}
+}
+
+func TestContainerContextWriteWithNoContainers(t *testing.T) {
+	out := bytes.NewBufferString("")
+	containers := []types.Container{}
+
+	contexts := []struct {
+		context  ContainerContext
+		expected string
+	}{
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "{{.Image}}",
+					Output: out,
+				},
+			},
+			"",
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "table {{.Image}}",
+					Output: out,
+				},
+			},
+			"IMAGE\n",
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "{{.Image}}",
+					Output: out,
+				},
+				Size: true,
+			},
+			"",
+		},
+		{
+			ContainerContext{
+				Context: Context{
+					Format: "table {{.Image}}",
+					Output: out,
+				},
+				Size: true,
+			},
+			"IMAGE               SIZE\n",
+		},
+	}
+
+	for _, context := range contexts {
+		context.context.Containers = containers
+		context.context.Write()
+		actual := out.String()
+		if actual != context.expected {
+			t.Fatalf("Expected \n%s, got \n%s", context.expected, actual)
+		}
+		// Clean buffer
+		out.Reset()
+	}
+}
+
+func TestImageContextWrite(t *testing.T) {
+	unixTime := time.Now().AddDate(0, 0, -1).Unix()
+	expectedTime := time.Unix(unixTime, 0).String()
+
+	contexts := []struct {
+		context  ImageContext
+		expected string
+	}{
+		// Errors
+		{
+			ImageContext{
+				Context: Context{
+					Format: "{{InvalidFunction}}",
+				},
+			},
+			`Template parsing error: template: :1: function "InvalidFunction" not defined
+`,
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "{{nil}}",
+				},
+			},
+			`Template parsing error: template: :1:2: executing "" at <nil>: nil is not a command
+`,
+		},
+		// Table Format
+		{
+			ImageContext{
+				Context: Context{
+					Format: "table",
+				},
+			},
+			`REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
+image               tag1                imageID1            24 hours ago        0 B
+image               <none>              imageID1            24 hours ago        0 B
+image               tag2                imageID2            24 hours ago        0 B
+<none>              <none>              imageID3            24 hours ago        0 B
+`,
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "table {{.Repository}}",
+				},
+			},
+			"REPOSITORY\nimage\nimage\nimage\n<none>\n",
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "table {{.Repository}}",
+				},
+				Digest: true,
+			},
+			`REPOSITORY          DIGEST
+image               <none>
+image               sha256:cbbf2f9a99b47fc460d422812b6a5adff7dfee951d8fa2e4a98caa0382cfbdbf
+image               <none>
+<none>              <none>
+`,
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "table {{.Repository}}",
+					Quiet:  true,
+				},
+			},
+			"REPOSITORY\nimage\nimage\nimage\n<none>\n",
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "table",
+					Quiet:  true,
+				},
+			},
+			"imageID1\nimageID1\nimageID2\nimageID3\n",
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "table",
+					Quiet:  false,
+				},
+				Digest: true,
+			},
+			`REPOSITORY          TAG                 DIGEST                                                                    IMAGE ID            CREATED             SIZE
+image               tag1                <none>                                                                    imageID1            24 hours ago        0 B
+image               <none>              sha256:cbbf2f9a99b47fc460d422812b6a5adff7dfee951d8fa2e4a98caa0382cfbdbf   imageID1            24 hours ago        0 B
+image               tag2                <none>                                                                    imageID2            24 hours ago        0 B
+<none>              <none>              <none>                                                                    imageID3            24 hours ago        0 B
+`,
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "table",
+					Quiet:  true,
+				},
+				Digest: true,
+			},
+			"imageID1\nimageID1\nimageID2\nimageID3\n",
+		},
+		// Raw Format
+		{
+			ImageContext{
+				Context: Context{
+					Format: "raw",
+				},
+			},
+			fmt.Sprintf(`repository: image
+tag: tag1
+image_id: imageID1
+created_at: %s
+virtual_size: 0 B
+
+repository: image
+tag: <none>
+image_id: imageID1
+created_at: %s
+virtual_size: 0 B
+
+repository: image
+tag: tag2
+image_id: imageID2
+created_at: %s
+virtual_size: 0 B
+
+repository: <none>
+tag: <none>
+image_id: imageID3
+created_at: %s
+virtual_size: 0 B
+
+`, expectedTime, expectedTime, expectedTime, expectedTime),
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "raw",
+				},
+				Digest: true,
+			},
+			fmt.Sprintf(`repository: image
+tag: tag1
+digest: <none>
+image_id: imageID1
+created_at: %s
+virtual_size: 0 B
+
+repository: image
+tag: <none>
+digest: sha256:cbbf2f9a99b47fc460d422812b6a5adff7dfee951d8fa2e4a98caa0382cfbdbf
+image_id: imageID1
+created_at: %s
+virtual_size: 0 B
+
+repository: image
+tag: tag2
+digest: <none>
+image_id: imageID2
+created_at: %s
+virtual_size: 0 B
+
+repository: <none>
+tag: <none>
+digest: <none>
+image_id: imageID3
+created_at: %s
+virtual_size: 0 B
+
+`, expectedTime, expectedTime, expectedTime, expectedTime),
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "raw",
+					Quiet:  true,
+				},
+			},
+			`image_id: imageID1
+image_id: imageID1
+image_id: imageID2
+image_id: imageID3
+`,
+		},
+		// Custom Format
+		{
+			ImageContext{
+				Context: Context{
+					Format: "{{.Repository}}",
+				},
+			},
+			"image\nimage\nimage\n<none>\n",
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "{{.Repository}}",
+				},
+				Digest: true,
+			},
+			"image\nimage\nimage\n<none>\n",
+		},
+	}
+
+	for _, context := range contexts {
+		images := []types.Image{
+			{ID: "imageID1", RepoTags: []string{"image:tag1"}, RepoDigests: []string{"image@sha256:cbbf2f9a99b47fc460d422812b6a5adff7dfee951d8fa2e4a98caa0382cfbdbf"}, Created: unixTime},
+			{ID: "imageID2", RepoTags: []string{"image:tag2"}, Created: unixTime},
+			{ID: "imageID3", RepoTags: []string{"<none>:<none>"}, RepoDigests: []string{"<none>@<none>"}, Created: unixTime},
+		}
+		out := bytes.NewBufferString("")
+		context.context.Output = out
+		context.context.Images = images
+		context.context.Write()
+		actual := out.String()
+		if actual != context.expected {
+			t.Fatalf("Expected \n%s, got \n%s", context.expected, actual)
+		}
+		// Clean buffer
+		out.Reset()
+	}
+}
+
+func TestImageContextWriteWithNoImage(t *testing.T) {
+	out := bytes.NewBufferString("")
+	images := []types.Image{}
+
+	contexts := []struct {
+		context  ImageContext
+		expected string
+	}{
+		{
+			ImageContext{
+				Context: Context{
+					Format: "{{.Repository}}",
+					Output: out,
+				},
+			},
+			"",
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "table {{.Repository}}",
+					Output: out,
+				},
+			},
+			"REPOSITORY\n",
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "{{.Repository}}",
+					Output: out,
+				},
+				Digest: true,
+			},
+			"",
+		},
+		{
+			ImageContext{
+				Context: Context{
+					Format: "table {{.Repository}}",
+					Output: out,
+				},
+				Digest: true,
+			},
+			"REPOSITORY          DIGEST\n",
+		},
+	}
+
+	for _, context := range contexts {
+		context.context.Images = images
+		context.context.Write()
+		actual := out.String()
+		if actual != context.expected {
+			t.Fatalf("Expected \n%s, got \n%s", context.expected, actual)
+		}
+		// Clean buffer
+		out.Reset()
+	}
+}

+ 18 - 63
api/client/images.go

@@ -1,19 +1,12 @@
 package client
 
 import (
-	"fmt"
-	"strings"
-	"text/tabwriter"
-	"time"
-
+	"github.com/docker/docker/api/client/formatter"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/filters"
 	Cli "github.com/docker/docker/cli"
 	"github.com/docker/docker/opts"
 	flag "github.com/docker/docker/pkg/mflag"
-	"github.com/docker/docker/pkg/stringid"
-	"github.com/docker/docker/reference"
-	"github.com/docker/go-units"
 )
 
 // CmdImages lists the images in a specified repository, or all top-level images if no repository is specified.
@@ -25,6 +18,7 @@ func (cli *DockerCli) CmdImages(args ...string) error {
 	all := cmd.Bool([]string{"a", "-all"}, false, "Show all images (default hides intermediate images)")
 	noTrunc := cmd.Bool([]string{"-no-trunc"}, false, "Don't truncate output")
 	showDigests := cmd.Bool([]string{"-digests"}, false, "Show digests")
+	format := cmd.String([]string{"-format"}, "", "Pretty-print images using a Go template")
 
 	flFilter := opts.NewListOpts(nil)
 	cmd.Var(&flFilter, []string{"f", "-filter"}, "Filter output based on conditions provided")
@@ -59,66 +53,27 @@ func (cli *DockerCli) CmdImages(args ...string) error {
 		return err
 	}
 
-	w := tabwriter.NewWriter(cli.out, 20, 1, 3, ' ', 0)
-	if !*quiet {
-		if *showDigests {
-			fmt.Fprintln(w, "REPOSITORY\tTAG\tDIGEST\tIMAGE ID\tCREATED\tSIZE")
+	f := *format
+	if len(f) == 0 {
+		if len(cli.ImagesFormat()) > 0 && !*quiet {
+			f = cli.ImagesFormat()
 		} else {
-			fmt.Fprintln(w, "REPOSITORY\tTAG\tIMAGE ID\tCREATED\tSIZE")
+			f = "table"
 		}
 	}
 
-	for _, image := range images {
-		ID := image.ID
-		if !*noTrunc {
-			ID = stringid.TruncateID(ID)
-		}
-
-		repoTags := image.RepoTags
-		repoDigests := image.RepoDigests
-
-		if len(repoTags) == 1 && repoTags[0] == "<none>:<none>" && len(repoDigests) == 1 && repoDigests[0] == "<none>@<none>" {
-			// dangling image - clear out either repoTags or repoDigsts so we only show it once below
-			repoDigests = []string{}
-		}
-
-		// combine the tags and digests lists
-		tagsAndDigests := append(repoTags, repoDigests...)
-		for _, repoAndRef := range tagsAndDigests {
-			// default repo, tag, and digest to none - if there's a value, it'll be set below
-			repo := "<none>"
-			tag := "<none>"
-			digest := "<none>"
-
-			if !strings.HasPrefix(repoAndRef, "<none>") {
-				ref, err := reference.ParseNamed(repoAndRef)
-				if err != nil {
-					return err
-				}
-				repo = ref.Name()
-
-				switch x := ref.(type) {
-				case reference.Canonical:
-					digest = x.Digest().String()
-				case reference.NamedTagged:
-					tag = x.Tag()
-				}
-			}
-
-			if !*quiet {
-				if *showDigests {
-					fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s ago\t%s\n", repo, tag, digest, ID, units.HumanDuration(time.Now().UTC().Sub(time.Unix(int64(image.Created), 0))), units.HumanSize(float64(image.Size)))
-				} else {
-					fmt.Fprintf(w, "%s\t%s\t%s\t%s ago\t%s\n", repo, tag, ID, units.HumanDuration(time.Now().UTC().Sub(time.Unix(int64(image.Created), 0))), units.HumanSize(float64(image.Size)))
-				}
-			} else {
-				fmt.Fprintln(w, ID)
-			}
-		}
+	imagesCtx := formatter.ImageContext{
+		Context: formatter.Context{
+			Output: cli.out,
+			Format: f,
+			Quiet:  *quiet,
+			Trunc:  !*noTrunc,
+		},
+		Digest: *showDigests,
+		Images: images,
 	}
 
-	if !*quiet {
-		w.Flush()
-	}
+	imagesCtx.Write()
+
 	return nil
 }

+ 11 - 8
api/client/ps.go

@@ -1,7 +1,7 @@
 package client
 
 import (
-	"github.com/docker/docker/api/client/ps"
+	"github.com/docker/docker/api/client/formatter"
 	"github.com/docker/docker/api/types"
 	"github.com/docker/docker/api/types/filters"
 	Cli "github.com/docker/docker/cli"
@@ -70,15 +70,18 @@ func (cli *DockerCli) CmdPs(args ...string) error {
 		}
 	}
 
-	psCtx := ps.Context{
-		Output: cli.out,
-		Format: f,
-		Quiet:  *quiet,
-		Size:   *size,
-		Trunc:  !*noTrunc,
+	psCtx := formatter.ContainerContext{
+		Context: formatter.Context{
+			Output: cli.out,
+			Format: f,
+			Quiet:  *quiet,
+			Trunc:  !*noTrunc,
+		},
+		Size:       *size,
+		Containers: containers,
 	}
 
-	ps.Format(psCtx, containers)
+	psCtx.Write()
 
 	return nil
 }

+ 0 - 140
api/client/ps/formatter.go

@@ -1,140 +0,0 @@
-package ps
-
-import (
-	"bytes"
-	"fmt"
-	"io"
-	"strings"
-	"text/tabwriter"
-	"text/template"
-
-	"github.com/docker/docker/api/types"
-)
-
-const (
-	tableFormatKey = "table"
-	rawFormatKey   = "raw"
-
-	defaultTableFormat = "table {{.ID}}\t{{.Image}}\t{{.Command}}\t{{.RunningFor}} ago\t{{.Status}}\t{{.Ports}}\t{{.Names}}"
-	defaultQuietFormat = "{{.ID}}"
-)
-
-// Context contains information required by the formatter to print the output as desired.
-type Context struct {
-	// Output is the output stream to which the formatted string is written.
-	Output io.Writer
-	// Format is used to choose raw, table or custom format for the output.
-	Format string
-	// Size when set to true will display the size of the output.
-	Size bool
-	// Quiet when set to true will simply print minimal information.
-	Quiet bool
-	// Trunc when set to true will truncate the output of certain fields such as Container ID.
-	Trunc bool
-}
-
-// Format helps to format the output using the parameters set in the Context.
-// Currently Format allow to display in raw, table or custom format the output.
-func Format(ctx Context, containers []types.Container) {
-	switch ctx.Format {
-	case tableFormatKey:
-		tableFormat(ctx, containers)
-	case rawFormatKey:
-		rawFormat(ctx, containers)
-	default:
-		customFormat(ctx, containers)
-	}
-}
-
-func rawFormat(ctx Context, containers []types.Container) {
-	if ctx.Quiet {
-		ctx.Format = `container_id: {{.ID}}`
-	} else {
-		ctx.Format = `container_id: {{.ID}}
-image: {{.Image}}
-command: {{.Command}}
-created_at: {{.CreatedAt}}
-status: {{.Status}}
-names: {{.Names}}
-labels: {{.Labels}}
-ports: {{.Ports}}
-`
-		if ctx.Size {
-			ctx.Format += `size: {{.Size}}
-`
-		}
-	}
-
-	customFormat(ctx, containers)
-}
-
-func tableFormat(ctx Context, containers []types.Container) {
-	ctx.Format = defaultTableFormat
-	if ctx.Quiet {
-		ctx.Format = defaultQuietFormat
-	}
-
-	customFormat(ctx, containers)
-}
-
-func customFormat(ctx Context, containers []types.Container) {
-	var (
-		table  bool
-		header string
-		format = ctx.Format
-		buffer = bytes.NewBufferString("")
-	)
-
-	if strings.HasPrefix(ctx.Format, tableKey) {
-		table = true
-		format = format[len(tableKey):]
-	}
-
-	format = strings.Trim(format, " ")
-	r := strings.NewReplacer(`\t`, "\t", `\n`, "\n")
-	format = r.Replace(format)
-
-	if table && ctx.Size {
-		format += "\t{{.Size}}"
-	}
-
-	tmpl, err := template.New("").Parse(format)
-	if err != nil {
-		buffer.WriteString(fmt.Sprintf("Template parsing error: %v\n", err))
-		buffer.WriteTo(ctx.Output)
-		return
-	}
-
-	for _, container := range containers {
-		containerCtx := &containerContext{
-			trunc: ctx.Trunc,
-			c:     container,
-		}
-		if err := tmpl.Execute(buffer, containerCtx); err != nil {
-			buffer = bytes.NewBufferString(fmt.Sprintf("Template parsing error: %v\n", err))
-			buffer.WriteTo(ctx.Output)
-			return
-		}
-		if table && len(header) == 0 {
-			header = containerCtx.fullHeader()
-		}
-		buffer.WriteString("\n")
-	}
-
-	if table {
-		if len(header) == 0 {
-			// if we still don't have a header, we didn't have any containers so we need to fake it to get the right headers from the template
-			containerCtx := &containerContext{}
-			tmpl.Execute(bytes.NewBufferString(""), containerCtx)
-			header = containerCtx.fullHeader()
-		}
-
-		t := tabwriter.NewWriter(ctx.Output, 20, 1, 3, ' ', 0)
-		t.Write([]byte(header))
-		t.Write([]byte("\n"))
-		buffer.WriteTo(t)
-		t.Flush()
-	} else {
-		buffer.WriteTo(ctx.Output)
-	}
-}

+ 0 - 213
api/client/ps/formatter_test.go

@@ -1,213 +0,0 @@
-package ps
-
-import (
-	"bytes"
-	"fmt"
-	"testing"
-	"time"
-
-	"github.com/docker/docker/api/types"
-)
-
-func TestFormat(t *testing.T) {
-	unixTime := time.Now().Add(-50 * time.Hour).Unix()
-	expectedTime := time.Unix(unixTime, 0).String()
-
-	contexts := []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: "table",
-			},
-			`CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
-containerID1        ubuntu              ""                  2 days ago                                                  foobar_baz
-containerID2        ubuntu              ""                  2 days ago                                                  foobar_bar
-`,
-		},
-		{
-			Context{
-				Format: "table {{.Image}}",
-			},
-			"IMAGE\nubuntu\nubuntu\n",
-		},
-		{
-			Context{
-				Format: "table {{.Image}}",
-				Size:   true,
-			},
-			"IMAGE               SIZE\nubuntu              0 B\nubuntu              0 B\n",
-		},
-		{
-			Context{
-				Format: "table {{.Image}}",
-				Quiet:  true,
-			},
-			"IMAGE\nubuntu\nubuntu\n",
-		},
-		{
-			Context{
-				Format: "table",
-				Quiet:  true,
-			},
-			"containerID1\ncontainerID2\n",
-		},
-		// Raw Format
-		{
-			Context{
-				Format: "raw",
-			},
-			fmt.Sprintf(`container_id: containerID1
-image: ubuntu
-command: ""
-created_at: %s
-status: 
-names: foobar_baz
-labels: 
-ports: 
-
-container_id: containerID2
-image: ubuntu
-command: ""
-created_at: %s
-status: 
-names: foobar_bar
-labels: 
-ports: 
-
-`, expectedTime, expectedTime),
-		},
-		{
-			Context{
-				Format: "raw",
-				Size:   true,
-			},
-			fmt.Sprintf(`container_id: containerID1
-image: ubuntu
-command: ""
-created_at: %s
-status: 
-names: foobar_baz
-labels: 
-ports: 
-size: 0 B
-
-container_id: containerID2
-image: ubuntu
-command: ""
-created_at: %s
-status: 
-names: foobar_bar
-labels: 
-ports: 
-size: 0 B
-
-`, expectedTime, expectedTime),
-		},
-		{
-			Context{
-				Format: "raw",
-				Quiet:  true,
-			},
-			"container_id: containerID1\ncontainer_id: containerID2\n",
-		},
-		// Custom Format
-		{
-			Context{
-				Format: "{{.Image}}",
-			},
-			"ubuntu\nubuntu\n",
-		},
-		{
-			Context{
-				Format: "{{.Image}}",
-				Size:   true,
-			},
-			"ubuntu\nubuntu\n",
-		},
-	}
-
-	for _, context := range contexts {
-		containers := []types.Container{
-			{ID: "containerID1", Names: []string{"/foobar_baz"}, Image: "ubuntu", Created: unixTime},
-			{ID: "containerID2", Names: []string{"/foobar_bar"}, Image: "ubuntu", Created: unixTime},
-		}
-		out := bytes.NewBufferString("")
-		context.context.Output = out
-		Format(context.context, containers)
-		actual := out.String()
-		if actual != context.expected {
-			t.Fatalf("Expected \n%s, got \n%s", context.expected, actual)
-		}
-		// Clean buffer
-		out.Reset()
-	}
-}
-
-func TestCustomFormatNoContainers(t *testing.T) {
-	out := bytes.NewBufferString("")
-	containers := []types.Container{}
-
-	contexts := []struct {
-		context  Context
-		expected string
-	}{
-		{
-			Context{
-				Format: "{{.Image}}",
-				Output: out,
-			},
-			"",
-		},
-		{
-			Context{
-				Format: "table {{.Image}}",
-				Output: out,
-			},
-			"IMAGE\n",
-		},
-		{
-			Context{
-				Format: "{{.Image}}",
-				Output: out,
-				Size:   true,
-			},
-			"",
-		},
-		{
-			Context{
-				Format: "table {{.Image}}",
-				Output: out,
-				Size:   true,
-			},
-			"IMAGE               SIZE\n",
-		},
-	}
-
-	for _, context := range contexts {
-		customFormat(context.context, containers)
-		actual := out.String()
-		if actual != context.expected {
-			t.Fatalf("Expected \n%s, got \n%s", context.expected, actual)
-		}
-		// Clean buffer
-		out.Reset()
-	}
-}

+ 5 - 4
cliconfig/config.go

@@ -47,10 +47,11 @@ func SetConfigDir(dir string) {
 
 // ConfigFile ~/.docker/config.json file info
 type ConfigFile struct {
-	AuthConfigs map[string]types.AuthConfig `json:"auths"`
-	HTTPHeaders map[string]string           `json:"HttpHeaders,omitempty"`
-	PsFormat    string                      `json:"psFormat,omitempty"`
-	filename    string                      // Note: not serialized - for internal use only
+	AuthConfigs  map[string]types.AuthConfig `json:"auths"`
+	HTTPHeaders  map[string]string           `json:"HttpHeaders,omitempty"`
+	PsFormat     string                      `json:"psFormat,omitempty"`
+	ImagesFormat string                      `json:"imagesFormat,omitempty"`
+	filename     string                      // Note: not serialized - for internal use only
 }
 
 // NewConfigFile initializes an empty configuration file for the given filename 'fn'

+ 4 - 1
contrib/completion/bash/docker

@@ -927,6 +927,9 @@ _docker_images() {
 			fi
 			return
 			;;
+                --format)
+			return
+			;;
 	esac
 
 	case "${words[$cword-2]}$prev=" in
@@ -941,7 +944,7 @@ _docker_images() {
 
 	case "$cur" in
 		-*)
-			COMPREPLY=( $( compgen -W "--all -a --digests --filter -f --help --no-trunc --quiet -q" -- "$cur" ) )
+			COMPREPLY=( $( compgen -W "--all -a --digests --filter -f --format --help --no-trunc --quiet -q" -- "$cur" ) )
 			;;
 		=)
 			return

+ 2 - 1
contrib/completion/zsh/_docker

@@ -692,8 +692,9 @@ __docker_subcommand() {
             _arguments $(__docker_arguments) \
                 $opts_help \
                 "($help -a --all)"{-a,--all}"[Show all images]" \
-                "($help)--digest[Show digests]" \
+                "($help)--digests[Show digests]" \
                 "($help)*"{-f=,--filter=}"[Filter values]:filter: " \
+                "($help)--format[Pretty-print containers using a Go template]:format: " \
                 "($help)--no-trunc[Do not truncate output]" \
                 "($help -q --quiet)"{-q,--quiet}"[Only show numeric IDs]" \
                 "($help -): :__docker_repositories" && ret=0

+ 7 - 0
docs/reference/commandline/cli.md

@@ -103,6 +103,12 @@ Docker's client uses this property. If this property is not set, the client
 falls back to the default table format. For a list of supported formatting
 directives, see the [**Formatting** section in the `docker ps` documentation](ps.md)
 
+The property `imagesFormat` specifies the default format for `docker images` output.
+When the `--format` flag is not provided with the `docker images` command,
+Docker's client uses this property. If this property is not set, the client
+falls back to the default table format. For a list of supported formatting
+directives, see the [**Formatting** section in the `docker images` documentation](images.md)
+
 Following is a sample `config.json` file:
 
     {
@@ -110,6 +116,7 @@ Following is a sample `config.json` file:
         "MyHeader": "MyValue"
       },
       "psFormat": "table {{.ID}}\\t{{.Image}}\\t{{.Command}}\\t{{.Labels}}"
+      "imagesFormat": "table {{.ID}}\\t{{.Repository}}\\t{{.Tag}}\\t{{.CreatedAt}}"
     }
 
 ### Notary

+ 50 - 0
docs/reference/commandline/images.md

@@ -177,3 +177,53 @@ In this example, with the `0.1` value, it returns an empty set because no matche
 
     $ docker images --filter "label=com.example.version=0.1"
     REPOSITORY          TAG                 IMAGE ID            CREATED              VIRTUAL SIZE
+
+## Formatting
+
+The formatting option (`--format`) will pretty print container output
+using a Go template.
+
+Valid placeholders for the Go template are listed below:
+
+Placeholder | Description
+---- | ----
+`.ID` | Image ID
+`.Repository` | Image repository
+`.Tag` | Image tag
+`.Digest` | Image digest
+`.CreatedSince` | Elapsed time since the image was created.
+`.CreatedAt` | Time when the image was created.
+`.Size` | Image disk size.
+
+When using the `--format` option, the `image` command will either
+output the data exactly as the template declares or, when using the
+`table` directive, will include column headers as well.
+
+The following example uses a template without headers and outputs the
+`ID` and `Repository` entries separated by a colon for all images:
+
+    $ docker images --format "{{.ID}}: {{.Repository}}"
+    77af4d6b9913: <none>
+    b6fa739cedf5: committ
+    78a85c484f71: <none>
+    30557a29d5ab: docker
+    5ed6274db6ce: <none>
+    746b819f315e: postgres
+    746b819f315e: postgres
+    746b819f315e: postgres
+    746b819f315e: postgres
+
+To list all images with their repository and tag in a table format you
+can use:
+
+    $ docker images --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}"
+    IMAGE ID            REPOSITORY                TAG
+    77af4d6b9913        <none>                    <none>
+    b6fa739cedf5        committ                   latest
+    78a85c484f71        <none>                    <none>
+    30557a29d5ab        docker                    latest
+    5ed6274db6ce        <none>                    <none>
+    746b819f315e        postgres                  9
+    746b819f315e        postgres                  9.3
+    746b819f315e        postgres                  9.3.5
+    746b819f315e        postgres                  latest

+ 55 - 9
integration-cli/docker_cli_images_test.go

@@ -2,6 +2,9 @@ package main
 
 import (
 	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
 	"reflect"
 	"sort"
 	"strings"
@@ -48,17 +51,17 @@ func (s *DockerSuite) TestImagesOrderedByCreationDate(c *check.C) {
 	testRequires(c, DaemonIsLinux)
 	id1, err := buildImage("order:test_a",
 		`FROM scratch
-		MAINTAINER dockerio1`, true)
+                MAINTAINER dockerio1`, true)
 	c.Assert(err, checker.IsNil)
 	time.Sleep(1 * time.Second)
 	id2, err := buildImage("order:test_c",
 		`FROM scratch
-		MAINTAINER dockerio2`, true)
+                MAINTAINER dockerio2`, true)
 	c.Assert(err, checker.IsNil)
 	time.Sleep(1 * time.Second)
 	id3, err := buildImage("order:test_b",
 		`FROM scratch
-		MAINTAINER dockerio3`, true)
+                MAINTAINER dockerio3`, true)
 	c.Assert(err, checker.IsNil)
 
 	out, _ := dockerCmd(c, "images", "-q", "--no-trunc")
@@ -81,17 +84,17 @@ func (s *DockerSuite) TestImagesFilterLabelMatch(c *check.C) {
 	imageName3 := "images_filter_test3"
 	image1ID, err := buildImage(imageName1,
 		`FROM scratch
-		 LABEL match me`, true)
+                 LABEL match me`, true)
 	c.Assert(err, check.IsNil)
 
 	image2ID, err := buildImage(imageName2,
 		`FROM scratch
-		 LABEL match="me too"`, true)
+                 LABEL match="me too"`, true)
 	c.Assert(err, check.IsNil)
 
 	image3ID, err := buildImage(imageName3,
 		`FROM scratch
-		 LABEL nomatch me`, true)
+                 LABEL nomatch me`, true)
 	c.Assert(err, check.IsNil)
 
 	out, _ := dockerCmd(c, "images", "--no-trunc", "-q", "-f", "label=match")
@@ -123,9 +126,9 @@ func (s *DockerSuite) TestImagesFilterSpaceTrimCase(c *check.C) {
 	imageName := "images_filter_test"
 	buildImage(imageName,
 		`FROM scratch
-		 RUN touch /test/foo
-		 RUN touch /test/bar
-		 RUN touch /test/baz`, true)
+                 RUN touch /test/foo
+                 RUN touch /test/bar
+                 RUN touch /test/baz`, true)
 
 	filters := []string{
 		"dangling=true",
@@ -233,3 +236,46 @@ func (s *DockerSuite) TestImagesFilterNameWithPort(c *check.C) {
 	out, _ = dockerCmd(c, "images", tag+":no-such-tag")
 	c.Assert(out, checker.Not(checker.Contains), tag)
 }
+
+func (s *DockerSuite) TestImagesFormat(c *check.C) {
+	// testRequires(c, DaemonIsLinux)
+	tag := "myimage"
+	dockerCmd(c, "tag", "busybox", tag+":v1")
+	dockerCmd(c, "tag", "busybox", tag+":v2")
+
+	out, _ := dockerCmd(c, "images", "--format", "{{.Repository}}", tag)
+	lines := strings.Split(strings.TrimSpace(string(out)), "\n")
+
+	expected := []string{"myimage", "myimage"}
+	var names []string
+	for _, l := range lines {
+		names = append(names, l)
+	}
+	c.Assert(expected, checker.DeepEquals, names, check.Commentf("Expected array with truncated names: %v, got: %v", expected, names))
+}
+
+// ImagesDefaultFormatAndQuiet
+func (s *DockerSuite) TestImagesFormatDefaultFormat(c *check.C) {
+	testRequires(c, DaemonIsLinux)
+
+	// create container 1
+	out, _ := dockerCmd(c, "run", "-d", "busybox", "true")
+	containerID1 := strings.TrimSpace(out)
+
+	// tag as foobox
+	out, _ = dockerCmd(c, "commit", containerID1, "myimage")
+	imageID := stringid.TruncateID(strings.TrimSpace(out))
+
+	config := `{
+		"imagesFormat": "{{ .ID }} default"
+}`
+	d, err := ioutil.TempDir("", "integration-cli-")
+	c.Assert(err, checker.IsNil)
+	defer os.RemoveAll(d)
+
+	err = ioutil.WriteFile(filepath.Join(d, "config.json"), []byte(config), 0644)
+	c.Assert(err, checker.IsNil)
+
+	out, _ = dockerCmd(c, "--config", d, "images", "-q", "myimage")
+	c.Assert(out, checker.Equals, imageID+"\n", check.Commentf("Expected to print only the image id, got %v\n", out))
+}

+ 1 - 1
integration-cli/docker_cli_ps_test.go

@@ -568,7 +568,7 @@ func (s *DockerSuite) TestPsFormatHeaders(c *check.C) {
 func (s *DockerSuite) TestPsDefaultFormatAndQuiet(c *check.C) {
 	testRequires(c, DaemonIsLinux)
 	config := `{
-		"psFormat": "{{ .ID }} default"
+		"psFormat": "default {{ .ID }}"
 }`
 	d, err := ioutil.TempDir("", "integration-cli-")
 	c.Assert(err, checker.IsNil)

+ 11 - 0
man/docker-images.1.md

@@ -40,6 +40,17 @@ versions.
 **-f**, **--filter**=[]
    Filters the output. The dangling=true filter finds unused images. While label=com.foo=amd64 filters for images with a com.foo value of amd64. The label=com.foo filter finds images with the label com.foo of any value.
 
+**--format**="*TEMPLATE*"
+   Pretty-print containers using a Go template.
+   Valid placeholders:
+      .ID - Image ID
+      .Repository - Image repository
+      .Tag - Image tag
+      .Digest - Image digest
+      .CreatedSince - Elapsed time since the image was created.
+      .CreatedAt - Time when the image was created..
+      .Size - Image disk size.
+
 **--help**
   Print usage statement