diff --git a/temporalcli/commands.batch.go b/temporalcli/commands.batch.go index eaf2a817..1f0a1e2c 100644 --- a/temporalcli/commands.batch.go +++ b/temporalcli/commands.batch.go @@ -70,6 +70,10 @@ func (c TemporalBatchListCommand) run(cctx *CommandContext, args []string) error } defer cl.Close() + // This is a listing command subject to json vs jsonl rules + cctx.Printer.StartList() + defer cctx.Printer.EndList() + pageFetcher := c.pageFetcher(cctx, cl) var nextPageToken []byte var jobsProcessed int @@ -80,9 +84,6 @@ func (c TemporalBatchListCommand) run(cctx *CommandContext, args []string) error } if pageIndex == 0 && len(page.GetOperationInfo()) == 0 { - if cctx.JSONOutput { - _ = cctx.Printer.PrintStructured([]any{}, printer.StructuredOptions{}) - } return nil } diff --git a/temporalcli/commands.batch_test.go b/temporalcli/commands.batch_test.go index eba6b72e..087217d4 100644 --- a/temporalcli/commands.batch_test.go +++ b/temporalcli/commands.batch_test.go @@ -97,7 +97,7 @@ func (s *SharedServerSuite) TestBatchJob_List() { ) s.NoError(res.Err) s.Empty(res.Stderr.String()) - s.Equal("[]\n", res.Stdout.String()) + s.Equal("[\n]\n", res.Stdout.String()) }) }) diff --git a/temporalcli/commands.gen.go b/temporalcli/commands.gen.go index 0218cb0a..fad0c1a9 100644 --- a/temporalcli/commands.gen.go +++ b/temporalcli/commands.gen.go @@ -48,8 +48,8 @@ func NewTemporalCommand(cctx *CommandContext) *TemporalCommand { s.Command.PersistentFlags().Var(&s.LogLevel, "log-level", "Log level. Accepted values: debug, info, warn, error, never.") s.LogFormat = NewStringEnum([]string{"text", "json"}, "text") s.Command.PersistentFlags().Var(&s.LogFormat, "log-format", "Log format. Accepted values: text, json.") - s.Output = NewStringEnum([]string{"text", "json"}, "text") - s.Command.PersistentFlags().VarP(&s.Output, "output", "o", "Data output format. Accepted values: text, json.") + s.Output = NewStringEnum([]string{"text", "json", "jsonl"}, "text") + s.Command.PersistentFlags().VarP(&s.Output, "output", "o", "Data output format. Accepted values: text, json, jsonl.") s.TimeFormat = NewStringEnum([]string{"relative", "iso", "raw"}, "relative") s.Command.PersistentFlags().Var(&s.TimeFormat, "time-format", "Time format. Accepted values: relative, iso, raw.") s.Color = NewStringEnum([]string{"always", "never", "auto"}, "auto") diff --git a/temporalcli/commands.go b/temporalcli/commands.go index 720cf7e6..ee427b03 100644 --- a/temporalcli/commands.go +++ b/temporalcli/commands.go @@ -381,12 +381,17 @@ func (c *TemporalCommand) preRun(cctx *CommandContext) error { } // Configure printer if not already on context - cctx.JSONOutput = c.Output.Value == "json" + cctx.JSONOutput = c.Output.Value == "json" || c.Output.Value == "jsonl" + // Only indent JSON if not jsonl + var jsonIndent string + if c.Output.Value == "json" { + jsonIndent = " " + } if cctx.Printer == nil { cctx.Printer = &printer.Printer{ Output: cctx.Options.Stdout, JSON: cctx.JSONOutput, - JSONIndent: " ", + JSONIndent: jsonIndent, JSONPayloadShorthand: !c.NoJsonShorthandPayloads, } switch c.TimeFormat.Value { diff --git a/temporalcli/commands.operator_cluster.go b/temporalcli/commands.operator_cluster.go index 9341b664..36539593 100644 --- a/temporalcli/commands.operator_cluster.go +++ b/temporalcli/commands.operator_cluster.go @@ -95,6 +95,10 @@ func (c *TemporalOperatorClusterListCommand) run(cctx *CommandContext, args []st } defer cl.Close() + // This is a listing command subject to json vs jsonl rules + cctx.Printer.StartList() + defer cctx.Printer.EndList() + var nextPageToken []byte var execsProcessed int for pageIndex := 0; ; pageIndex++ { diff --git a/temporalcli/commands.workflow_view.go b/temporalcli/commands.workflow_view.go index 94467eaa..145fa131 100644 --- a/temporalcli/commands.workflow_view.go +++ b/temporalcli/commands.workflow_view.go @@ -193,6 +193,10 @@ func (c *TemporalWorkflowListCommand) run(cctx *CommandContext, args []string) e } defer cl.Close() + // This is a listing command subject to json vs jsonl rules + cctx.Printer.StartList() + defer cctx.Printer.EndList() + // Build request and start looping. We always use default page size regardless // of user-defined limit, because we're ok w/ extra page data and the default // is not clearly defined. diff --git a/temporalcli/commandsmd/commands.md b/temporalcli/commandsmd/commands.md index 0ce1307f..1ac65eb2 100644 --- a/temporalcli/commandsmd/commands.md +++ b/temporalcli/commandsmd/commands.md @@ -52,7 +52,7 @@ This document has a specific structure used by a parser. Here are the rules: * `--env-file` (string) - File to read all environments (defaults to `$HOME/.config/temporalio/temporal.yaml`). * `--log-level` (string-enum) - Log level. Options: debug, info, warn, error, never. Default: info. * `--log-format` (string-enum) - Log format. Options: text, json. Default: text. -* `--output`, `-o` (string-enum) - Data output format. Options: text, json. Default: text. +* `--output`, `-o` (string-enum) - Data output format. Options: text, json, jsonl. Default: text. * `--time-format` (string-enum) - Time format. Options: relative, iso, raw. Default: relative. * `--color` (string-enum) - Set coloring. Options: always, never, auto. Default: auto. * `--no-json-shorthand-payloads` (bool) - Always all payloads as raw payloads even if they are JSON. diff --git a/temporalcli/internal/printer/printer.go b/temporalcli/internal/printer/printer.go index 180802c6..0412364d 100644 --- a/temporalcli/internal/printer/printer.go +++ b/temporalcli/internal/printer/printer.go @@ -21,14 +21,18 @@ type Colorer func(string, ...interface{}) string type Printer struct { // Must always be present - Output io.Writer - JSON bool + Output io.Writer + JSON bool + // This is unset/empty in JSONL mode JSONIndent string JSONPayloadShorthand bool // Only used for non-JSON, defaults to RFC3339 FormatTime func(time.Time) string // Only used for non-JSON, defaults to color.Magenta TableHeaderColorer Colorer + + listMode bool + listModeFirstJSON bool // True until first JSON printed } // Ignored during JSON output @@ -50,6 +54,42 @@ func (p *Printer) Printlnf(s string, v ...any) { p.Println(fmt.Sprintf(s, v...)) } +// When called for JSON with indent, this will create an initial bracket and +// make sure all [Printer.PrintStructured] calls get commas properly to appear +// as a list (but the indention and multiline posture of the JSON remains). When +// called for JSON without indent, this will make sure all +// [Printer.PrintStructured] is on its own line (i.e. JSONL mode). When called +// for non-JSON, this is a no-op. +// +// [Printer.EndList] must be called at the end. If this is called twice it will +// panic. This and the end call are not safe for concurrent use. +func (p *Printer) StartList() { + if p.listMode { + panic("already in list mode") + } + p.listMode, p.listModeFirstJSON = true, true + // Write initial bracket when non-jsonl + if p.JSON && p.JSONIndent != "" { + // Don't need newline, we count on initial object to do that + p.Output.Write([]byte("[")) + } +} + +// Must be called after [Printer.StartList] or will panic. See Godoc on that +// function for more details. +func (p *Printer) EndList() { + if !p.listMode { + panic("not in list mode") + } + p.listMode, p.listModeFirstJSON = false, false + // Write ending bracket when non-jsonl + if p.JSON && p.JSONIndent != "" { + // We prepend a newline because non-jsonl list mode doesn't do so after each + // line to help with commas + p.Output.Write([]byte("\n]\n")) + } +} + type StructuredOptions struct { // Derived if not present. Ignored for JSON printing. Fields []string @@ -171,19 +211,40 @@ func (p *Printer) writef(s string, v ...any) { } func (p *Printer) printJSON(v any, options StructuredOptions) error { + // Before printing, if we're in non-jsonl list mode, we must append a comma + // and a newline if we're not the first JSON seen. + nonJSONLListMode := p.listMode && p.JSON && p.JSONIndent != "" + if nonJSONLListMode { + var prepend string + if p.listModeFirstJSON { + p.listModeFirstJSON = false + prepend = "\n" + } else { + prepend = ",\n" + } + if _, err := p.Output.Write([]byte(prepend)); err != nil { + return err + } + } + + // Print JSON shorthandPayloads := p.JSONPayloadShorthand if options.OverrideJSONPayloadShorthand != nil { shorthandPayloads = *options.OverrideJSONPayloadShorthand } - b, err := p.jsonVal(v, p.JSONIndent, shorthandPayloads) - if err != nil { + if b, err := p.jsonVal(v, p.JSONIndent, shorthandPayloads); err != nil { + return err + } else if _, err := p.Output.Write(b); err != nil { return err } - _, err = p.Output.Write(b) - if err == nil { - _, err = p.Output.Write([]byte("\n")) + + // Do not print a newline if in non-jsonl list mode + if !nonJSONLListMode { + if _, err := p.Output.Write([]byte("\n")); err != nil { + return err + } } - return err + return nil } func (p *Printer) jsonVal(v any, indent string, shorthandPayloads bool) ([]byte, error) { diff --git a/temporalcli/internal/printer/printer_test.go b/temporalcli/internal/printer/printer_test.go index c52f0101..a5e7e1ee 100644 --- a/temporalcli/internal/printer/printer_test.go +++ b/temporalcli/internal/printer/printer_test.go @@ -15,7 +15,7 @@ import ( // * Text printer specific and non-specific fields and all sorts of table options // * JSON printer -func TestTextPrinter(t *testing.T) { +func TestPrinter_Text(t *testing.T) { type MyStruct struct { Foo string Bar bool @@ -73,3 +73,70 @@ func normalizeMultiline(s string) string { } return ret } + +func TestPrinter_JSON(t *testing.T) { + var buf bytes.Buffer + + // With indentation + p := printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.Equal(t, `{ + "foo": "bar" +} +`, buf.String()) + + // Without indentation + buf.Reset() + p = printer.Printer{Output: &buf, JSON: true} + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.Equal(t, "{\"foo\":\"bar\"}\n", buf.String()) +} + +func TestPrinter_JSONList(t *testing.T) { + var buf bytes.Buffer + + // With indentation + p := printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} + p.StartList() + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, printer.StructuredOptions{})) + p.EndList() + require.Equal(t, `[ +{ + "foo": "bar" +}, +{ + "baz": "qux" +} +] +`, buf.String()) + + // Without indentation + buf.Reset() + p = printer.Printer{Output: &buf, JSON: true} + p.StartList() + p.Println("should not print") + require.NoError(t, p.PrintStructured(map[string]string{"foo": "bar"}, printer.StructuredOptions{})) + require.NoError(t, p.PrintStructured(map[string]string{"baz": "qux"}, printer.StructuredOptions{})) + p.EndList() + require.Equal(t, "{\"foo\":\"bar\"}\n{\"baz\":\"qux\"}\n", buf.String()) + + // Empty with indentation + buf.Reset() + p = printer.Printer{Output: &buf, JSON: true, JSONIndent: " "} + p.StartList() + p.Println("should not print") + p.EndList() + require.Equal(t, "[\n]\n", buf.String()) + + // Empty without indentation + buf.Reset() + p = printer.Printer{Output: &buf, JSON: true} + p.StartList() + p.Println("should not print") + p.EndList() + require.Equal(t, "", buf.String()) +}