From cae0d9ef791189f60344085e20cb5dcf9e6cff9f Mon Sep 17 00:00:00 2001 From: Cassowary Rusnov Date: Tue, 10 Jan 2023 18:51:38 -0800 Subject: [PATCH] Introduce a JSON output table mechanic - Create JSONTable as a proxy/extension to tablewriter which can also output JSON. - Implement machine readable output for `server list` and `recipe list` --- cli/internal/new.go | 4 +- cli/recipe/list.go | 11 +- cli/server/list.go | 8 +- pkg/formatter/formatter.go | 7 +- pkg/jsontable/jsontable.go | 203 +++++++++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 pkg/jsontable/jsontable.go diff --git a/cli/internal/new.go b/cli/internal/new.go index 38ad42da..cdeb9f0b 100644 --- a/cli/internal/new.go +++ b/cli/internal/new.go @@ -7,12 +7,12 @@ import ( "coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/formatter" + "coopcloud.tech/abra/pkg/jsontable" "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/abra/pkg/secret" "coopcloud.tech/abra/pkg/ssh" "github.com/AlecAivazis/survey/v2" - "github.com/olekukonko/tablewriter" "github.com/sirupsen/logrus" "github.com/urfave/cli" ) @@ -144,7 +144,7 @@ func NewAction(c *cli.Context) error { } var secrets AppSecrets - var secretTable *tablewriter.Table + var secretTable *jsontable.JSONTable if Secrets { if err := ssh.EnsureHostKey(NewAppServer); err != nil { logrus.Fatal(err) diff --git a/cli/recipe/list.go b/cli/recipe/list.go index dcb72870..8e7476e6 100644 --- a/cli/recipe/list.go +++ b/cli/recipe/list.go @@ -27,6 +27,7 @@ var recipeListCommand = cli.Command{ Aliases: []string{"ls"}, Flags: []cli.Flag{ internal.DebugFlag, + internal.MachineReadableFlag, patternFlag, }, Before: internal.SubCommandBefore, @@ -66,10 +67,14 @@ var recipeListCommand = cli.Command{ } } - table.SetCaption(true, fmt.Sprintf("total recipes: %v", len)) - if table.NumLines() > 0 { - table.Render() + if internal.MachineReadable { + table.SetCaption(false, "") + table.JSONRender() + } else { + table.SetCaption(true, fmt.Sprintf("total recipes: %v", len)) + table.Render() + } } return nil diff --git a/cli/server/list.go b/cli/server/list.go index f588b018..32e53f8e 100644 --- a/cli/server/list.go +++ b/cli/server/list.go @@ -18,6 +18,7 @@ var serverListCommand = cli.Command{ Usage: "List managed servers", Flags: []cli.Flag{ internal.DebugFlag, + internal.MachineReadableFlag, }, Before: internal.SubCommandBefore, Action: func(c *cli.Context) error { @@ -29,8 +30,11 @@ var serverListCommand = cli.Command{ tableColumns := []string{"name", "host", "user", "port"} table := formatter.CreateTable(tableColumns) - defer table.Render() - + if internal.MachineReadable { + defer table.JSONRender() + } else { + defer table.Render() + } serverNames, err := config.ReadServerNames() if err != nil { logrus.Fatal(err) diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index b68887d3..ca6600b1 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -6,7 +6,8 @@ import ( "time" "github.com/docker/go-units" - "github.com/olekukonko/tablewriter" + // "github.com/olekukonko/tablewriter" + "coopcloud.tech/abra/pkg/jsontable" "github.com/schollz/progressbar/v3" "github.com/sirupsen/logrus" ) @@ -32,8 +33,8 @@ func HumanDuration(timestamp int64) string { } // CreateTable prepares a table layout for output. -func CreateTable(columns []string) *tablewriter.Table { - table := tablewriter.NewWriter(os.Stdout) +func CreateTable(columns []string) *jsontable.JSONTable { + table := jsontable.NewJSONTable(os.Stdout) table.SetAutoWrapText(false) table.SetHeader(columns) return table diff --git a/pkg/jsontable/jsontable.go b/pkg/jsontable/jsontable.go new file mode 100644 index 00000000..350c8bc7 --- /dev/null +++ b/pkg/jsontable/jsontable.go @@ -0,0 +1,203 @@ +package jsontable + +import ( + "fmt" + "github.com/olekukonko/tablewriter" + "io" +) + +// A quick-and-dirty proxy/emulator of tablewriter to enable more easy machine readable output +// - Does not strictly support types, just quoted or unquoted values +// - Does not support nested values. +// If a datalabel is set with SetDataLabel(true, "..."), that will be used as the key for teh data of the table, +// otherwise if the caption is set with SetCaption(true, "..."), the data label will be set to the default of +// "rows", otherwise the table will output as a JSON list. +// +// Proxys all actions through to the tablewriter except addrow and addbatch, which it does at render time +// + +type JSONTable struct { + out io.Writer + colsize int + rows [][]string + keys []string + quoted []bool // hack to do output typing, quoted vs. unquoted + hasDataLabel bool + dataLabel string + hasCaption bool + caption string // the actual caption + hasCaptionLabel bool + captionLabel string // the key in the dictionary for the caption + tbl *tablewriter.Table +} + +func writeChar(w io.Writer, c byte) { + w.Write([]byte{c}) + +} + +func NewJSONTable(writer io.Writer) *JSONTable { + t := &JSONTable{ + out: writer, + colsize: 0, + rows: [][]string{}, + keys: []string{}, + quoted: []bool{}, + hasDataLabel: false, + dataLabel: "rows", + hasCaption: false, + caption: "", + hasCaptionLabel: false, + captionLabel: "caption", + tbl: tablewriter.NewWriter(writer), + } + return t +} + +func (t *JSONTable) NumLines() int { + // JSON only but reflects a shared state. + return len(t.rows) +} + +func (t *JSONTable) SetHeader(keys []string) { + // Set the keys value which will assign each column to the keys. + // Note that we'll ignore values that are beyond the length of the keys list + t.colsize = len(keys) + t.keys = []string{} + for _, k := range keys { + t.keys = append(t.keys, k) + t.quoted = append(t.quoted, true) + } + t.tbl.SetHeader(keys) +} + +func (t *JSONTable) SetColumnQuoting(quoting []bool) { + // Specify which columns are quoted or unquoted in output + // JSON only + for i := 0; i < t.colsize; i++ { + t.quoted[i] = quoting[i] + } +} + +func (t *JSONTable) Append(row []string) { + // We'll just append whatever to the rows list. If they fix the keys after appending rows, it'll work as + // expected. + // We should detect if the row is narrower than the key list tho. + // JSON only (but we use the rows later when rendering a regular table) + t.rows = append(t.rows, row) +} + +func (t *JSONTable) Render() { + // Load the table with rows and render. + // Proxy only + for _, row := range t.rows { + t.tbl.Append(row) + } + + t.tbl.Render() +} + +func (t *JSONTable) _JSONRenderInner() { + // JSON only + // Render the list of dictionaries to the writer. + //// inner render loop + writeChar(t.out, '[') + for rowidx, row := range t.rows { + if rowidx != 0 { + writeChar(t.out, ',') + } + writeChar(t.out, '{') + for keyidx, key := range t.keys { + value := "nil" + if keyidx <= len(row) { + value = row[keyidx] + } + if keyidx != 0 { + writeChar(t.out, ',') + } + if t.quoted[keyidx] { + fmt.Fprintf(t.out, "\"%s\":\"%s\"", key, value) + } else { + fmt.Fprintf(t.out, "\"%s\":%s", key, value) + } + } + writeChar(t.out, '}') + } + writeChar(t.out, ']') + +} + +func (t *JSONTable) JSONRender() { + // write JSON table to output + // JSON only + + if t.hasDataLabel || t.hasCaption { + // dict mode + writeChar(t.out, '{') + + if t.hasCaption { + fmt.Fprintf(t.out, "\"%s\":\"%s\",", t.captionLabel, t.caption) + + } + fmt.Fprintf(t.out, "\"%s\":", t.dataLabel) + + } + + // write list + t._JSONRenderInner() + + if t.hasDataLabel || t.hasCaption { + // dict mode + writeChar(t.out, '}') + } + +} + +func (t *JSONTable) SetCaption(caption bool, captionText ...string) { + t.hasCaption = caption + if len(captionText) == 1 { + t.caption = captionText[0] + } + t.tbl.SetCaption(caption, captionText...) +} + +func (t *JSONTable) SetCaptionLabel(captionLabel bool, captionLabelText ...string) { + // JSON only + t.hasCaptionLabel = captionLabel + if len(captionLabelText) == 1 { + t.captionLabel = captionLabelText[0] + } +} + +func (t *JSONTable) SetDataLabel(dataLabel bool, dataLabelText ...string) { + // JSON only + t.hasDataLabel = dataLabel + if len(dataLabelText) == 1 { + t.dataLabel = dataLabelText[0] + } +} + +func (t *JSONTable) AppendBulk(rows [][]string) { + // JSON only but reflects shared state + for _, row := range rows { + t.Append(row) + } +} + +/// Stuff we should implement but we just proxy for now. + +func (t *JSONTable) SetAutoMergeCellsByColumnIndex(cols []int) { + // FIXME + t.tbl.SetAutoMergeCellsByColumnIndex(cols) +} + +func (t *JSONTable) SetAutoMergeCells(auto bool) { + // FIXME + t.tbl.SetAutoMergeCells(auto) +} + +//// Stub functions +func (t *JSONTable) SetAutoWrapText(auto bool) { + t.tbl.SetAutoWrapText(auto) + return +}