Skip to content

Commit

Permalink
feat: add --template and support --format go-template=TEMPLATE ex…
Browse files Browse the repository at this point in the history
…perience (#1377)

Signed-off-by: Billy Zha <jinzha1@microsoft.com>
  • Loading branch information
qweeah authored May 14, 2024
1 parent 96d64da commit 674b16e
Show file tree
Hide file tree
Showing 19 changed files with 480 additions and 101 deletions.
80 changes: 47 additions & 33 deletions cmd/oras/internal/display/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,120 +30,134 @@ import (
"oras.land/oras/cmd/oras/internal/display/metadata/text"
"oras.land/oras/cmd/oras/internal/display/metadata/tree"
"oras.land/oras/cmd/oras/internal/display/status"
"oras.land/oras/cmd/oras/internal/errors"
"oras.land/oras/cmd/oras/internal/option"
)

// NewPushHandler returns status and metadata handlers for push command.
func NewPushHandler(out io.Writer, format string, tty *os.File, verbose bool) (status.PushHandler, metadata.PushHandler) {
func NewPushHandler(out io.Writer, format option.Format, tty *os.File, verbose bool) (status.PushHandler, metadata.PushHandler, error) {
var statusHandler status.PushHandler
if tty != nil {
statusHandler = status.NewTTYPushHandler(tty)
} else if format == "" {
} else if format.Type == "" {
statusHandler = status.NewTextPushHandler(out, verbose)
} else {
statusHandler = status.NewDiscardHandler()
}

var metadataHandler metadata.PushHandler
switch format {
switch format.Type {
case "":
metadataHandler = text.NewPushHandler(out)
case "json":
case option.FormatTypeJSON.Name:
metadataHandler = json.NewPushHandler(out)
case option.FormatTypeGoTemplate.Name:
metadataHandler = template.NewPushHandler(out, format.Template)
default:
metadataHandler = template.NewPushHandler(out, format)
return nil, nil, errors.UnsupportedFormatTypeError(format.Type)
}
return statusHandler, metadataHandler
return statusHandler, metadataHandler, nil
}

// NewAttachHandler returns status and metadata handlers for attach command.
func NewAttachHandler(out io.Writer, format string, tty *os.File, verbose bool) (status.AttachHandler, metadata.AttachHandler) {
func NewAttachHandler(out io.Writer, format option.Format, tty *os.File, verbose bool) (status.AttachHandler, metadata.AttachHandler, error) {
var statusHandler status.AttachHandler
if tty != nil {
statusHandler = status.NewTTYAttachHandler(tty)
} else if format == "" {
} else if format.Type == "" {
statusHandler = status.NewTextAttachHandler(out, verbose)
} else {
statusHandler = status.NewDiscardHandler()
}

var metadataHandler metadata.AttachHandler
switch format {
switch format.Type {
case "":
metadataHandler = text.NewAttachHandler(out)
case "json":
case option.FormatTypeJSON.Name:
metadataHandler = json.NewAttachHandler(out)
case option.FormatTypeGoTemplate.Name:
metadataHandler = template.NewAttachHandler(out, format.Template)
default:
metadataHandler = template.NewAttachHandler(out, format)
return nil, nil, errors.UnsupportedFormatTypeError(format.Type)
}
return statusHandler, metadataHandler
return statusHandler, metadataHandler, nil
}

// NewPullHandler returns status and metadata handlers for pull command.
func NewPullHandler(out io.Writer, format string, path string, tty *os.File, verbose bool) (status.PullHandler, metadata.PullHandler) {
func NewPullHandler(out io.Writer, format option.Format, path string, tty *os.File, verbose bool) (status.PullHandler, metadata.PullHandler, error) {
var statusHandler status.PullHandler
if tty != nil {
statusHandler = status.NewTTYPullHandler(tty)
} else if format == "" {
} else if format.Type == "" {
statusHandler = status.NewTextPullHandler(out, verbose)
} else {
statusHandler = status.NewDiscardHandler()
}

var metadataHandler metadata.PullHandler
switch format {
switch format.Type {
case "":
metadataHandler = text.NewPullHandler(out)
case "json":
case option.FormatTypeJSON.Name:
metadataHandler = json.NewPullHandler(out, path)
case option.FormatTypeGoTemplate.Name:
metadataHandler = template.NewPullHandler(out, path, format.Template)
default:
metadataHandler = template.NewPullHandler(out, path, format)
return nil, nil, errors.UnsupportedFormatTypeError(format.Type)
}
return statusHandler, metadataHandler
return statusHandler, metadataHandler, nil
}

// NewDiscoverHandler returns status and metadata handlers for discover command.
func NewDiscoverHandler(out io.Writer, outputType string, path string, rawReference string, desc ocispec.Descriptor, verbose bool) metadata.DiscoverHandler {
switch outputType {
case "tree", "":
return tree.NewDiscoverHandler(out, path, desc, verbose)
case "table":
return table.NewDiscoverHandler(out, rawReference, desc, verbose)
case "json":
return json.NewDiscoverHandler(out, desc, path)
func NewDiscoverHandler(out io.Writer, format option.Format, path string, rawReference string, desc ocispec.Descriptor, verbose bool) (metadata.DiscoverHandler, error) {
var handler metadata.DiscoverHandler
switch format.Type {
case option.FormatTypeTree.Name, "":
handler = tree.NewDiscoverHandler(out, path, desc, verbose)
case option.FormatTypeTable.Name:
handler = table.NewDiscoverHandler(out, rawReference, desc, verbose)
case option.FormatTypeJSON.Name:
handler = json.NewDiscoverHandler(out, desc, path)
case option.FormatTypeGoTemplate.Name:
handler = template.NewDiscoverHandler(out, desc, path, format.Template)
default:
return template.NewDiscoverHandler(out, desc, path, outputType)
return nil, errors.UnsupportedFormatTypeError(format.Type)
}
return handler, nil
}

// NewManifestFetchHandler returns a manifest fetch handler.
func NewManifestFetchHandler(out io.Writer, format string, outputDescriptor, pretty bool, outputPath string) (metadata.ManifestFetchHandler, content.ManifestFetchHandler) {
func NewManifestFetchHandler(out io.Writer, format option.Format, outputDescriptor, pretty bool, outputPath string) (metadata.ManifestFetchHandler, content.ManifestFetchHandler, error) {
var metadataHandler metadata.ManifestFetchHandler
var contentHandler content.ManifestFetchHandler

switch format {
switch format.Type {
case "":
// raw
if outputDescriptor {
metadataHandler = descriptor.NewManifestFetchHandler(out, pretty)
} else {
metadataHandler = metadata.NewDiscardHandler()
}
case "json":
case option.FormatTypeJSON.Name:
// json
metadataHandler = json.NewManifestFetchHandler(out)
if outputPath == "" {
contentHandler = content.NewDiscardHandler()
}
default:
case option.FormatTypeGoTemplate.Name:
// go template
metadataHandler = template.NewManifestFetchHandler(out, format)
metadataHandler = template.NewManifestFetchHandler(out, format.Template)
if outputPath == "" {
contentHandler = content.NewDiscardHandler()
}
default:
return nil, nil, errors.UnsupportedFormatTypeError(format.Type)
}

if contentHandler == nil {
contentHandler = content.NewManifestFetchHandler(out, pretty, outputPath)
}
return metadataHandler, contentHandler
return metadataHandler, contentHandler, nil
}
8 changes: 8 additions & 0 deletions cmd/oras/internal/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ import (
// RegistryErrorPrefix is the commandline prefix for errors from registry.
const RegistryErrorPrefix = "Error response from registry:"

// UnsupportedFormatTypeError generates the error message for an invalid type.
type UnsupportedFormatTypeError string

// Error implements the error interface.
func (e UnsupportedFormatTypeError) Error() string {
return "unsupported format type: " + string(e)
}

// Error is the error type for CLI error messaging.
type Error struct {
Err error
Expand Down
131 changes: 120 additions & 11 deletions cmd/oras/internal/option/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,130 @@ limitations under the License.

package option

import "github.com/spf13/pflag"
import (
"bytes"
"fmt"
"strings"
"text/tabwriter"

// Format is a flag to format metadata into output.
"github.com/spf13/cobra"
"github.com/spf13/pflag"
oerrors "oras.land/oras/cmd/oras/internal/errors"
)

// FormatType represents a format type.
type FormatType struct {
// Name is the format type name.
Name string
// Usage is the usage string in help doc.
Usage string
// HasParams indicates whether the format type has parameters.
HasParams bool
}

// WithUsage returns a new format type with provided usage string.
func (ft *FormatType) WithUsage(usage string) *FormatType {
return &FormatType{
Name: ft.Name,
HasParams: ft.HasParams,
Usage: usage,
}
}

// format types
var (
FormatTypeJSON = &FormatType{
Name: "json",
Usage: "Print in JSON format",
}
FormatTypeGoTemplate = &FormatType{
Name: "go-template",
Usage: "Print output using the given Go template",
HasParams: true,
}
FormatTypeTable = &FormatType{
Name: "table",
Usage: "Get direct referrers and output in table format",
}
FormatTypeTree = &FormatType{
Name: "tree",
Usage: "Get referrers recursively and print in tree format",
}
)

// Format contains input and parsed options for formatted output flags.
type Format struct {
Template string
FormatFlag string
Type string
Template string
AllowedTypes []*FormatType
}

// ApplyFlag implements FlagProvider.ApplyFlag.
func (opts *Format) ApplyFlags(fs *pflag.FlagSet) {
const name = "format"
if fs.Lookup(name) != nil {
// allow command to overwrite the flag
return
}
fs.StringVar(&opts.Template, name, "", `[Experimental] Format output using a custom template:
'json': Print in JSON format
'$TEMPLATE': Print output using the given Go template.`)
buf := bytes.NewBufferString("[Experimental] Format output using a custom template:")
w := tabwriter.NewWriter(buf, 0, 0, 2, ' ', 0)
for _, t := range opts.AllowedTypes {
_, _ = fmt.Fprintf(w, "\n'%s':\t%s", t.Name, t.Usage)
}
w.Flush()
// apply flags
fs.StringVar(&opts.FormatFlag, "format", opts.FormatFlag, buf.String())
fs.StringVar(&opts.Template, "template", "", "[Experimental] Template string used to format output")
}

// Parse parses the input format flag.
func (opts *Format) Parse(_ *cobra.Command) error {
if err := opts.parseFlag(); err != nil {
return err
}

if opts.Type == "" {
// flag not specified
return nil
}

if opts.Type == FormatTypeGoTemplate.Name && opts.Template == "" {
return &oerrors.Error{
Err: fmt.Errorf("%q format specified but no template given", opts.Type),
Recommendation: fmt.Sprintf("use `--format %s=TEMPLATE` to specify the template", opts.Type),
}
}

var optionalTypes []string
for _, t := range opts.AllowedTypes {
if opts.Type == t.Name {
// type validation passed
return nil
}
optionalTypes = append(optionalTypes, t.Name)
}
return &oerrors.Error{
Err: fmt.Errorf("invalid format type: %q", opts.Type),
Recommendation: fmt.Sprintf("supported types: %s", strings.Join(optionalTypes, ", ")),
}
}

func (opts *Format) parseFlag() error {
opts.Type = opts.FormatFlag
if opts.Template != "" {
// template explicitly set
if opts.Type != FormatTypeGoTemplate.Name {
return fmt.Errorf("--template must be used with --format %s", FormatTypeGoTemplate.Name)
}
return nil
}

for _, t := range opts.AllowedTypes {
if !t.HasParams {
continue
}
prefix := t.Name + "="
if strings.HasPrefix(opts.FormatFlag, prefix) {
// parse type and add parameter to template
opts.Type = t.Name
opts.Template = opts.FormatFlag[len(prefix):]
}
}
return nil
}
7 changes: 6 additions & 1 deletion cmd/oras/root/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,18 @@ Example - Attach file to the manifest tagged 'v1' in an OCI image layout folder
opts.FlagDescription = "[Preview] attach to an arch-specific subject"
_ = cmd.MarkFlagRequired("artifact-type")
opts.EnableDistributionSpecFlag()
opts.AllowedTypes = []*option.FormatType{option.FormatTypeJSON, option.FormatTypeGoTemplate}
option.ApplyFlags(&opts, cmd.Flags())
return oerrors.Command(cmd, &opts.Target)
}

func runAttach(cmd *cobra.Command, opts *attachOptions) error {
ctx, logger := command.GetLogger(cmd, &opts.Common)
displayStatus, displayMetadata, err := display.NewAttachHandler(cmd.OutOrStdout(), opts.Format, opts.TTY, opts.Verbose)
if err != nil {
return err
}

annotations, err := opts.LoadManifestAnnotations()
if err != nil {
return err
Expand All @@ -119,7 +125,6 @@ func runAttach(cmd *cobra.Command, opts *attachOptions) error {
Recommendation: `To attach to an existing artifact, please provide files via argument or annotations via flag "--annotation". Run "oras attach -h" for more options and examples`,
}
}
displayStatus, displayMetadata := display.NewAttachHandler(cmd.OutOrStdout(), opts.Template, opts.TTY, opts.Verbose)

// prepare manifest
store, err := file.New("")
Expand Down
43 changes: 43 additions & 0 deletions cmd/oras/root/attach_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
Copyright The ORAS Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package root

import (
"context"
"testing"

"github.com/spf13/cobra"
"oras.land/oras/cmd/oras/internal/errors"
"oras.land/oras/cmd/oras/internal/option"
)

func Test_runAttach_errType(t *testing.T) {
// prpare
cmd := &cobra.Command{}
cmd.SetContext(context.Background())

// test
opts := &attachOptions{
Format: option.Format{
Type: "unknown",
},
}
got := runAttach(cmd, opts).Error()
want := errors.UnsupportedFormatTypeError(opts.Format.Type).Error()
if got != want {
t.Fatalf("got %v, want %v", got, want)
}
}
Loading

0 comments on commit 674b16e

Please sign in to comment.