add output formatting control to cli (#329)

* add output formatting control to cli

Adds the capacity for the CLI to output either a
text table or a json blob to the terminal.  Table is
the default behavior, json is toggled with the --json
flag.
This commit is contained in:
Keepers 2022-07-13 14:15:08 -06:00 committed by GitHub
parent 36346548d7
commit 39c85e1a84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 313 additions and 28 deletions

View File

@ -1,14 +1,12 @@
package backup
import (
"os"
"github.com/pkg/errors"
"github.com/segmentio/cli"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/alcionai/corso/cli/config"
"github.com/alcionai/corso/cli/print"
"github.com/alcionai/corso/cli/utils"
"github.com/alcionai/corso/pkg/logger"
"github.com/alcionai/corso/pkg/repository"
@ -136,15 +134,8 @@ func listExchangeCmd(cmd *cobra.Command, args []string) error {
return errors.Wrap(err, "Failed to list backups in the repository")
}
// TODO: Can be used to print in alternative forms (e.g. json)
p, err := cli.Format("text", os.Stdout)
if err != nil {
return err
}
defer p.Flush()
for _, rp := range rps {
p.Print(*rp)
}
print.Backups(rps)
return nil
}
@ -185,13 +176,7 @@ func detailsExchangeCmd(cmd *cobra.Command, args []string) error {
return errors.Wrap(err, "Failed to get backup details in the repository")
}
// TODO: Can be used to print in alternative forms
p, err := cli.Format("json", os.Stdout)
if err != nil {
return err
}
defer p.Flush()
p.Print(*rpd)
print.Entries(rpd.Entries)
return nil
}

View File

@ -10,6 +10,7 @@ import (
"github.com/alcionai/corso/cli/backup"
"github.com/alcionai/corso/cli/config"
"github.com/alcionai/corso/cli/print"
"github.com/alcionai/corso/cli/repo"
"github.com/alcionai/corso/cli/restore"
"github.com/alcionai/corso/pkg/logger"
@ -57,6 +58,7 @@ func handleCorsoCmd(cmd *cobra.Command, args []string) error {
func Handle() {
corsoCmd.Flags().BoolP("version", "v", version, "current version info")
corsoCmd.PersistentFlags().StringVar(&cfgFile, "config-file", "", "config file (default is $HOME/.corso)")
print.AddOutputFlag(corsoCmd)
corsoCmd.CompletionOptions.DisableDefaultCmd = true

87
src/cli/print/print.go Normal file
View File

@ -0,0 +1,87 @@
package print
import (
"encoding/json"
"fmt"
"os"
"github.com/alcionai/corso/pkg/backup"
"github.com/spf13/cobra"
"github.com/tidwall/pretty"
"github.com/tomlazar/table"
)
var outputAsJSON bool
// adds the --output flag to the provided command.
func AddOutputFlag(parent *cobra.Command) {
parent.PersistentFlags().BoolVar(&outputAsJSON, "json", false, "output data in JSON format")
}
type Printable interface {
// should list the property names of the values surfaced in Values()
Headers() []string
// list of values for tabular or csv formatting
// if the backing data is nil or otherwise missing,
// values should provide an empty string as opposed to skipping entries
Values() []string
}
// Prints the backups to the terminal with stdout.
func Backups(bs []*backup.Backup) {
ps := []Printable{}
for _, b := range bs {
ps = append(ps, b)
}
printAll(ps)
}
// Prints the entries to the terminal with stdout.
func Entries(des []backup.DetailsEntry) {
ps := []Printable{}
for _, de := range des {
ps = append(ps, de)
}
printAll(ps)
}
// printAll prints the slice of printable items,
// according to the caller's requested format.
func printAll(ps []Printable) {
if len(ps) == 0 {
return
}
if outputAsJSON {
outputJSON(ps)
return
}
outputTable(ps)
}
// output to stdout the list of printable structs as json
func outputJSON(ps []Printable) {
bs, err := json.Marshal(ps)
if err != nil {
fmt.Fprintf(os.Stderr, "error formatting results to json: %v\n", err)
return
}
fmt.Println(string(pretty.Pretty(bs)))
}
// output to stdout the list of printable structs in a table
func outputTable(ps []Printable) {
t := table.Table{
Headers: ps[0].Headers(),
Rows: [][]string{},
}
for _, p := range ps {
t.Rows = append(t.Rows, p.Values())
}
_ = t.WriteTable(
os.Stdout,
&table.Config{
ShowIndex: false,
Color: false,
AlternateColors: false,
})
}

View File

@ -13,11 +13,13 @@ require (
github.com/microsoftgraph/msgraph-sdk-go v0.28.0
github.com/microsoftgraph/msgraph-sdk-go-core v0.26.1
github.com/pkg/errors v0.9.1
github.com/segmentio/cli v0.5.0
github.com/spf13/cobra v1.4.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.12.0
github.com/stretchr/testify v1.8.0
github.com/tidwall/pretty v1.2.0
github.com/tomlazar/table v0.1.2
github.com/zeebo/assert v1.1.0
go.uber.org/zap v1.21.0
golang.org/x/tools v0.1.11
)
@ -60,7 +62,11 @@ require (
github.com/klauspost/cpuid/v2 v2.0.14 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/microsoft/kiota-http-go v0.5.2 // indirect
github.com/microsoft/kiota-serialization-text-go v0.4.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
@ -76,6 +82,7 @@ require (
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.35.0 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rs/xid v1.4.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect

View File

@ -238,8 +238,18 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microsoft/kiota-abstractions-go v0.8.1 h1:ACCwRwddJYOx+SRqfgcR8Wo8PZTd4g+JMa8lY8ABy+4=
github.com/microsoft/kiota-abstractions-go v0.8.1/go.mod h1:05aCidCKhzer+yfhGeePaMUY3MH+wrAkQztBVEreTtc=
github.com/microsoft/kiota-authentication-azure-go v0.3.0 h1:iLyy5qldAjBiYMGMk1r/rJkcmARA8cKboiN7/XbRxv4=
@ -313,14 +323,14 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/cli v0.5.0 h1:AssNAdZV728i8u6LWfq9pqoeQGxiyXmTt0jrCfnjcx0=
github.com/segmentio/cli v0.5.0/go.mod h1:rktB/5TnLUnEBYdRG+jlAii0bkHWpnrb+jpXiFkoPxs=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
@ -351,6 +361,10 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/subosito/gotenv v1.3.0 h1:mjC+YW8QpAdXibNi+vNWgzmgBH4+5l5dCXv8cNysBLI=
github.com/subosito/gotenv v1.3.0/go.mod h1:YzJjq/33h7nrwdY+iHMhEOEEbW0ovIz0tB6t6PwAXzs=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tomlazar/table v0.1.2 h1:DP8f62FzZAZk8oavepm1v/oyf4ni3/LMHWNlOinmleg=
github.com/tomlazar/table v0.1.2/go.mod h1:IecZnpep9f/BatHacfh+++ftE+lFONN8BVPi9nx5U1w=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -512,6 +526,7 @@ golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -540,6 +555,8 @@ golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -108,13 +108,12 @@ func (op *BackupOperation) Run(ctx context.Context) error {
}
func (op *BackupOperation) createBackupModels(ctx context.Context, snapID string, details *backup.Details) error {
err := op.modelStore.Put(ctx, kopia.BackupDetailsModel, details)
err := op.modelStore.Put(ctx, kopia.BackupDetailsModel, &details.DetailsModel)
if err != nil {
return errors.Wrap(err, "creating backupdetails model")
}
err = op.modelStore.Put(ctx, kopia.BackupModel,
backup.New(snapID, string(details.ModelStoreID)))
err = op.modelStore.Put(ctx, kopia.BackupModel, backup.New(snapID, string(details.ModelStoreID)))
if err != nil {
return errors.Wrap(err, "creating backup model")
}

View File

@ -23,6 +23,28 @@ type Backup struct {
// - Backup "Specification"
}
// Headers returns the human-readable names of properties in a Backup
// for printing out to a terminal in a columnar display.
func (b Backup) Headers() []string {
return []string{
"Creation Time",
"Stable ID",
"Snapshot ID",
"Details ID",
}
}
// Values returns the values matching the Headers list for printing
// out to a terminal in a columnar display.
func (b Backup) Values() []string {
return []string{
b.CreationTime.Format(time.RFC3339Nano),
string(b.StableID),
b.SnapshotID,
b.DetailsID,
}
}
func New(snapshotID, detailsID string) *Backup {
return &Backup{
CreationTime: time.Now(),
@ -31,10 +53,17 @@ func New(snapshotID, detailsID string) *Backup {
}
}
// Details describes what was stored in a Backup
type Details struct {
// DetailsModel describes what was stored in a Backup
type DetailsModel struct {
model.BaseModel
Entries []DetailsEntry `json:"entries"`
}
// Details augments the core with a mutex for processing.
// Should be sliced back to d.DetailsModel for storage and
// printing.
type Details struct {
DetailsModel
// internal
mu sync.Mutex `json:"-"`
@ -48,6 +77,31 @@ type DetailsEntry struct {
ItemInfo
}
// Headers returns the human-readable names of properties in a DetailsEntry
// for printing out to a terminal in a columnar display.
func (de DetailsEntry) Headers() []string {
hs := []string{"Repo Ref"}
if de.ItemInfo.Exchange != nil {
hs = append(hs, de.ItemInfo.Exchange.Headers()...)
}
if de.ItemInfo.Sharepoint != nil {
hs = append(hs, de.ItemInfo.Sharepoint.Headers()...)
}
return hs
}
// Values returns the values matching the Headers list.
func (de DetailsEntry) Values() []string {
vs := []string{de.RepoRef}
if de.ItemInfo.Exchange != nil {
vs = append(vs, de.ItemInfo.Exchange.Values()...)
}
if de.ItemInfo.Sharepoint != nil {
vs = append(vs, de.ItemInfo.Sharepoint.Values()...)
}
return vs
}
// ItemInfo is a oneOf that contains service specific
// information about the item it tracks
type ItemInfo struct {
@ -62,6 +116,18 @@ type ExchangeInfo struct {
Received time.Time `json:"received"`
}
// Headers returns the human-readable names of properties in an ExchangeInfo
// for printing out to a terminal in a columnar display.
func (e ExchangeInfo) Headers() []string {
return []string{"Sender", "Subject", "Received"}
}
// Values returns the values matching the Headers list for printing
// out to a terminal in a columnar display.
func (e ExchangeInfo) Values() []string {
return []string{e.Sender, e.Subject, e.Received.Format(time.RFC3339Nano)}
}
// SharepointInfo describes a sharepoint item
// TODO: Implement this. This is currently here
// just to illustrate usage
@ -72,3 +138,15 @@ func (d *Details) Add(repoRef string, info ItemInfo) {
defer d.mu.Unlock()
d.Entries = append(d.Entries, DetailsEntry{RepoRef: repoRef, ItemInfo: info})
}
// Headers returns the human-readable names of properties in a SharepointInfo
// for printing out to a terminal in a columnar display.
func (s SharepointInfo) Headers() []string {
return []string{}
}
// Values returns the values matching the Headers list for printing
// out to a terminal in a columnar display.
func (s SharepointInfo) Values() []string {
return []string{}
}

View File

@ -0,0 +1,108 @@
package backup_test
import (
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/zeebo/assert"
"github.com/alcionai/corso/internal/model"
"github.com/alcionai/corso/pkg/backup"
)
type BackupSuite struct {
suite.Suite
}
func TestBackupSuite(t *testing.T) {
suite.Run(t, new(BackupSuite))
}
func (suite *BackupSuite) TestBackup_HeadersValues() {
t := suite.T()
now := time.Now()
b := backup.Backup{
BaseModel: model.BaseModel{
StableID: model.ID("stable"),
},
CreationTime: now,
SnapshotID: "snapshot",
DetailsID: "details",
}
expectHs := []string{
"Creation Time",
"Stable ID",
"Snapshot ID",
"Details ID",
}
hs := b.Headers()
assert.DeepEqual(t, expectHs, hs)
expectVs := []string{
now.Format(time.RFC3339Nano),
"stable",
"snapshot",
"details",
}
vs := b.Values()
assert.DeepEqual(t, expectVs, vs)
}
func (suite *BackupSuite) TestDetailsEntry_HeadersValues() {
now := time.Now()
nowStr := now.Format(time.RFC3339Nano)
table := []struct {
name string
entry backup.DetailsEntry
expectHs []string
expectVs []string
}{
{
name: "no info",
entry: backup.DetailsEntry{
RepoRef: "reporef",
},
expectHs: []string{"Repo Ref"},
expectVs: []string{"reporef"},
},
{
name: "exhange info",
entry: backup.DetailsEntry{
RepoRef: "reporef",
ItemInfo: backup.ItemInfo{
Exchange: &backup.ExchangeInfo{
Sender: "sender",
Subject: "subject",
Received: now,
},
},
},
expectHs: []string{"Repo Ref", "Sender", "Subject", "Received"},
expectVs: []string{"reporef", "sender", "subject", nowStr},
},
{
name: "sharepoint info",
entry: backup.DetailsEntry{
RepoRef: "reporef",
ItemInfo: backup.ItemInfo{
Sharepoint: &backup.SharepointInfo{},
},
},
expectHs: []string{"Repo Ref"},
expectVs: []string{"reporef"},
},
}
for _, test := range table {
suite.T().Run(test.name, func(t *testing.T) {
hs := test.entry.Headers()
assert.DeepEqual(t, test.expectHs, hs)
vs := test.entry.Values()
assert.DeepEqual(t, test.expectVs, vs)
})
}
}

View File

@ -606,7 +606,9 @@ func (suite *ExchangeSourceSuite) TestIdPath() {
func (suite *ExchangeSourceSuite) TestExchangeRestore_FilterDetails() {
makeDeets := func(refs ...string) *backup.Details {
deets := &backup.Details{
DetailsModel: backup.DetailsModel{
Entries: []backup.DetailsEntry{},
},
}
for _, r := range refs {
deets.Entries = append(deets.Entries, backup.DetailsEntry{