From ba05723618da8b986819bc04773275240db29d39 Mon Sep 17 00:00:00 2001 From: Pieter Noordhuis Date: Mon, 4 May 2026 14:12:42 +0200 Subject: [PATCH] Replace fatih/color with cmdio-internal ANSI helpers Co-authored-by: Isaac --- NOTICE | 4 - .../config/mutator/python/python_mutator.go | 8 +- .../mutator/python/python_mutator_test.go | 3 +- bundle/render/render_text_output.go | 33 ++----- bundle/render/render_text_output_test.go | 37 +------- bundle/run/job.go | 12 +-- cmd/labs/project/fetcher.go | 6 +- cmd/labs/project/installer.go | 7 +- cmd/labs/project/project.go | 4 +- experimental/aitools/cmd/install.go | 3 +- .../aitools/lib/installer/installer.go | 3 +- experimental/ssh/internal/client/client.go | 5 +- go.mod | 1 - go.sum | 4 +- libs/apps/logstream/formatter.go | 22 +++-- libs/apps/logstream/formatter_test.go | 6 +- libs/apps/logstream/streamer.go | 6 +- libs/apps/logstream/streamer_test.go | 15 ++-- libs/cmdio/color.go | 90 +++++++++++++++++++ libs/cmdio/color_test.go | 75 ++++++++++++++++ libs/cmdio/jsoncolor.go | 12 --- libs/cmdio/paged_template.go | 9 +- libs/cmdio/paged_template_test.go | 18 ++-- libs/cmdio/pager_test.go | 8 +- libs/cmdio/render.go | 88 +++++++++--------- libs/cmdio/render_test.go | 2 +- libs/databrickscfg/cfgpickers/clusters.go | 24 ++--- libs/databrickscfg/cfgpickers/warehouses.go | 15 ++-- .../cfgpickers/warehouses_test.go | 5 +- libs/log/handler/colors.go | 69 +++++++------- libs/log/handler/colors_test.go | 20 ++--- libs/log/handler/friendly.go | 4 +- 32 files changed, 366 insertions(+), 252 deletions(-) create mode 100644 libs/cmdio/color.go create mode 100644 libs/cmdio/color_test.go diff --git a/NOTICE b/NOTICE index 132b3e62efe..942cf66055f 100644 --- a/NOTICE +++ b/NOTICE @@ -143,10 +143,6 @@ charmbracelet/lipgloss - https://github.com/charmbracelet/lipgloss Copyright (c) 2021-2025 Charmbracelet, Inc License - https://github.com/charmbracelet/lipgloss/blob/master/LICENSE -fatih/color - https://github.com/fatih/color -Copyright (c) 2013 Fatih Arslan -License - https://github.com/fatih/color/blob/main/LICENSE.md - Masterminds/semver - https://github.com/Masterminds/semver Copyright (C) 2014-2019, Matt Butcher and Matt Farina License - https://github.com/Masterminds/semver/blob/master/LICENSE.txt diff --git a/bundle/config/mutator/python/python_mutator.go b/bundle/config/mutator/python/python_mutator.go index c20e172c00f..44e19b276a3 100644 --- a/bundle/config/mutator/python/python_mutator.go +++ b/bundle/config/mutator/python/python_mutator.go @@ -18,8 +18,8 @@ import ( "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/logdiag" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go/logger" - "github.com/fatih/color" "github.com/databricks/cli/libs/python" @@ -386,7 +386,7 @@ func (m *pythonMutator) runPythonMutator(ctx context.Context, root dyn.Value, op diagnostic := diag.Diagnostic{ Severity: diag.Error, Summary: fmt.Sprintf("python mutator process failed: %q, use --debug to enable logging", processErr), - Detail: explainProcessErr(stderrBuf.String()), + Detail: explainProcessErr(ctx, stderrBuf.String()), } return dyn.InvalidValue, diag.Diagnostics{diagnostic} @@ -424,10 +424,10 @@ or activate the environment before running CLI commands: // explainProcessErr provides additional explanation for common errors. // It's meant to be the best effort, and not all errors are covered. // Output should be used only used for error reporting. -func explainProcessErr(stderr string) string { +func explainProcessErr(ctx context.Context, stderr string) string { // implemented in cpython/Lib/runpy.py and portable across Python 3.x, including pypy if strings.Contains(stderr, "Error while finding module specification for 'databricks.bundles.build'") { - summary := color.CyanString("Explanation: ") + "'databricks-bundles' library is not installed in the Python environment.\n" + summary := cmdio.Cyan(ctx, "Explanation: ") + "'databricks-bundles' library is not installed in the Python environment.\n" return stderr + "\n" + summary + "\n" + pythonInstallExplanation } diff --git a/bundle/config/mutator/python/python_mutator_test.go b/bundle/config/mutator/python/python_mutator_test.go index 5ea8868b170..cf81da5f78c 100644 --- a/bundle/config/mutator/python/python_mutator_test.go +++ b/bundle/config/mutator/python/python_mutator_test.go @@ -20,6 +20,7 @@ import ( "github.com/databricks/cli/bundle" "github.com/databricks/cli/bundle/config" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/process" "github.com/stretchr/testify/assert" ) @@ -488,7 +489,7 @@ or activate the environment before running CLI commands: venv_path: .venv ` - out := explainProcessErr(stderr) + out := explainProcessErr(cmdio.MockDiscard(t.Context()), stderr) assert.Equal(t, expected, out) } diff --git a/bundle/render/render_text_output.go b/bundle/render/render_text_output.go index 4b892ff219c..b1f0c6442d1 100644 --- a/bundle/render/render_text_output.go +++ b/bundle/render/render_text_output.go @@ -10,26 +10,11 @@ import ( "text/template" "github.com/databricks/cli/bundle" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/logdiag" "github.com/databricks/databricks-sdk-go/service/iam" - "github.com/fatih/color" ) -var renderFuncMap = template.FuncMap{ - "red": color.RedString, - "green": color.GreenString, - "blue": color.BlueString, - "yellow": color.YellowString, - "magenta": color.MagentaString, - "cyan": color.CyanString, - "bold": func(format string, a ...any) string { - return color.New(color.Bold).Sprintf(format, a...) - }, - "italic": func(format string, a ...any) string { - return color.New(color.Italic).Sprintf(format, a...) - }, -} - const summaryHeaderTemplate = `{{- if .Name -}} Name: {{ .Name | bold }} {{- if .Target }} @@ -82,13 +67,13 @@ func buildTrailer(ctx context.Context) string { info := logdiag.Copy(ctx) var parts []string if info.Errors > 0 { - parts = append(parts, color.RedString(pluralize(info.Errors, "error", "errors"))) + parts = append(parts, cmdio.Red(ctx, pluralize(info.Errors, "error", "errors"))) } if info.Warnings > 0 { - parts = append(parts, color.YellowString(pluralize(info.Warnings, "warning", "warnings"))) + parts = append(parts, cmdio.Yellow(ctx, pluralize(info.Warnings, "warning", "warnings"))) } if info.Recommendations > 0 { - parts = append(parts, color.BlueString(pluralize(info.Recommendations, "recommendation", "recommendations"))) + parts = append(parts, cmdio.Blue(ctx, pluralize(info.Recommendations, "recommendation", "recommendations"))) } switch { case len(parts) >= 3: @@ -101,7 +86,7 @@ func buildTrailer(ctx context.Context) string { return fmt.Sprintf("Found %s\n", parts[0]) default: // No diagnostics to print. - return color.GreenString("Validation OK!\n") + return cmdio.Green(ctx, "Validation OK!\n") } } @@ -118,7 +103,7 @@ func renderSummaryHeaderTemplate(ctx context.Context, out io.Writer, b *bundle.B } } - t := template.Must(template.New("summary").Funcs(renderFuncMap).Parse(summaryHeaderTemplate)) + t := template.Must(template.New("summary").Funcs(cmdio.RenderFuncMap(ctx)).Parse(summaryHeaderTemplate)) err := t.Execute(out, map[string]any{ "Name": b.Config.Bundle.Name, "Target": b.Config.Bundle.Target, @@ -179,7 +164,7 @@ func RenderSummary(ctx context.Context, out io.Writer, b *bundle.Bundle) error { } } - if err := renderResourcesTemplate(out, resourceGroups); err != nil { + if err := renderResourcesTemplate(ctx, out, resourceGroups); err != nil { return fmt.Errorf("failed to render resources template: %w", err) } @@ -187,7 +172,7 @@ func RenderSummary(ctx context.Context, out io.Writer, b *bundle.Bundle) error { } // Helper function to sort and render resource groups using the template -func renderResourcesTemplate(out io.Writer, resourceGroups []ResourceGroup) error { +func renderResourcesTemplate(ctx context.Context, out io.Writer, resourceGroups []ResourceGroup) error { // Sort everything to ensure consistent output slices.SortFunc(resourceGroups, func(a, b ResourceGroup) int { return cmp.Compare(a.GroupName, b.GroupName) @@ -198,7 +183,7 @@ func renderResourcesTemplate(out io.Writer, resourceGroups []ResourceGroup) erro }) } - t := template.Must(template.New("resources").Funcs(renderFuncMap).Parse(resourcesTemplate)) + t := template.Must(template.New("resources").Funcs(cmdio.RenderFuncMap(ctx)).Parse(resourcesTemplate)) return t.Execute(out, resourceGroups) } diff --git a/bundle/render/render_text_output_test.go b/bundle/render/render_text_output_test.go index 3d424445396..4a6b777c1c2 100644 --- a/bundle/render/render_text_output_test.go +++ b/bundle/render/render_text_output_test.go @@ -18,7 +18,6 @@ import ( "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/pipelines" "github.com/databricks/databricks-sdk-go/service/serving" - "github.com/fatih/color" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -26,20 +25,13 @@ import ( func TestRenderSummaryHeaderTemplate_nilBundle(t *testing.T) { writer := &bytes.Buffer{} - err := renderSummaryHeaderTemplate(t.Context(), writer, nil) + err := renderSummaryHeaderTemplate(cmdio.MockDiscard(t.Context()), writer, nil) require.NoError(t, err) assert.Equal(t, "", writer.String()) } func TestRenderDiagnosticsSummary(t *testing.T) { - // Disable colors for consistent test output - oldNoColor := color.NoColor - color.NoColor = true - defer func() { - color.NoColor = oldNoColor - }() - testCases := []struct { name string bundle *bundle.Bundle @@ -114,7 +106,7 @@ func TestRenderDiagnosticsSummary(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - ctx := logdiag.InitContext(t.Context()) + ctx := logdiag.InitContext(cmdio.MockDiscard(t.Context())) logdiag.SetCollect(ctx, true) // Collect diagnostics instead of outputting to stderr // Simulate diagnostic counts by logging fake diagnostics @@ -144,13 +136,6 @@ type renderDiagnosticsTestCase struct { } func TestRenderDiagnostics(t *testing.T) { - // Disable colors for consistent test output - oldNoColor := color.NoColor - color.NoColor = true - defer func() { - color.NoColor = oldNoColor - }() - testCases := []renderDiagnosticsTestCase{ { name: "empty diagnostics", @@ -286,14 +271,7 @@ func TestRenderDiagnostics(t *testing.T) { } func TestRenderSummaryTemplate_nilBundle(t *testing.T) { - // Disable colors for consistent test output - oldNoColor := color.NoColor - color.NoColor = true - defer func() { - color.NoColor = oldNoColor - }() - - ctx := logdiag.InitContext(t.Context()) + ctx := logdiag.InitContext(cmdio.MockDiscard(t.Context())) writer := &bytes.Buffer{} err := renderSummaryHeaderTemplate(ctx, writer, nil) @@ -306,14 +284,7 @@ func TestRenderSummaryTemplate_nilBundle(t *testing.T) { } func TestRenderSummary(t *testing.T) { - ctx := t.Context() - - // Disable colors for consistent test output - oldNoColor := color.NoColor - color.NoColor = true - defer func() { - color.NoColor = oldNoColor - }() + ctx := cmdio.MockDiscard(t.Context()) // Create a mock bundle with various resources b := &bundle.Bundle{ diff --git a/bundle/run/job.go b/bundle/run/job.go index 506d45e9175..04b357682fe 100644 --- a/bundle/run/job.go +++ b/bundle/run/job.go @@ -15,7 +15,6 @@ import ( "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" "github.com/databricks/databricks-sdk-go/service/jobs" - "github.com/fatih/color" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" ) @@ -50,9 +49,6 @@ func isSuccess(task jobs.RunTask) bool { func (r *jobRunner) logFailedTasks(ctx context.Context, runId int64) { w := r.bundle.WorkspaceClient(ctx) - red := color.New(color.FgRed).SprintFunc() - green := color.New(color.FgGreen).SprintFunc() - yellow := color.New(color.FgYellow).SprintFunc() run, err := w.Jobs.GetRun(ctx, jobs.GetRunRequest{ RunId: runId, }) @@ -65,21 +61,21 @@ func (r *jobRunner) logFailedTasks(ctx context.Context, runId int64) { } for _, task := range run.Tasks { if isSuccess(task) { - log.Infof(ctx, "task %s completed successfully", green(task.TaskKey)) + log.Infof(ctx, "task %s completed successfully", cmdio.Green(ctx, task.TaskKey)) } else if isFailed(task) { taskInfo, err := w.Jobs.GetRunOutput(ctx, jobs.GetRunOutputRequest{ RunId: task.RunId, }) if err != nil { - log.Errorf(ctx, "task %s failed. Unable to fetch error trace: %s", red(task.TaskKey), err) + log.Errorf(ctx, "task %s failed. Unable to fetch error trace: %s", cmdio.Red(ctx, task.TaskKey), err) continue } cmdio.Log(ctx, progress.NewTaskErrorEvent(task.TaskKey, taskInfo.Error, taskInfo.ErrorTrace)) log.Errorf(ctx, "Task %s failed!\nError:\n%s\nTrace:\n%s", - red(task.TaskKey), taskInfo.Error, taskInfo.ErrorTrace) + cmdio.Red(ctx, task.TaskKey), taskInfo.Error, taskInfo.ErrorTrace) } else { log.Infof(ctx, "task %s is in state %s", - yellow(task.TaskKey), task.State.LifeCycleState) + cmdio.Yellow(ctx, task.TaskKey), task.State.LifeCycleState) } } } diff --git a/cmd/labs/project/fetcher.go b/cmd/labs/project/fetcher.go index 7f240ab010d..c6969ae1ba7 100644 --- a/cmd/labs/project/fetcher.go +++ b/cmd/labs/project/fetcher.go @@ -9,8 +9,8 @@ import ( "strings" "github.com/databricks/cli/cmd/labs/github" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/log" - "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -64,7 +64,7 @@ func NewInstaller(cmd *cobra.Command, name string, offlineInstall bool) (install if err != nil { return nil, fmt.Errorf("load: %w", err) } - cmd.PrintErrln(color.YellowString("Installing %s in development mode from %s", prj.Name, wd)) + cmd.PrintErrln(cmdio.Yellow(cmd.Context(), fmt.Sprintf("Installing %s in development mode from %s", prj.Name, wd))) return &devInstallation{ Project: prj, Command: cmd, @@ -141,7 +141,7 @@ func (f *fetcher) checkReleasedVersions(cmd *cobra.Command, version string, offl log.Debugf(ctx, "Latest %s version is: %s", f.name, versions[0].Version) return versions[0].Version, nil } - cmd.PrintErrln(color.YellowString("[WARNING] Installing unreleased version: %s", version)) + cmd.PrintErrln(cmdio.Yellow(ctx, "[WARNING] Installing unreleased version: "+version)) return version, nil } diff --git a/cmd/labs/project/installer.go b/cmd/labs/project/installer.go index f3d4bc7d6c4..32a74b6808f 100644 --- a/cmd/labs/project/installer.go +++ b/cmd/labs/project/installer.go @@ -20,7 +20,6 @@ import ( "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/sql" - "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -152,8 +151,8 @@ func (i *installer) Upgrade(ctx context.Context) error { return nil } -func (i *installer) warningf(text string, v ...any) { - i.cmd.PrintErrln(color.YellowString(text, v...)) +func (i *installer) warning(s string) { + i.cmd.PrintErrln(cmdio.Yellow(i.cmd.Context(), s)) } func (i *installer) cleanupLib(ctx context.Context) error { @@ -288,7 +287,7 @@ func (i *installer) installPythonDependencies(ctx context.Context, spec string) process.WithCombinedOutput(&buf), process.WithDir(libDir)) if err != nil { - i.warningf(buf.String()) + i.warning(buf.String()) return fmt.Errorf("failed to install dependencies of %s", spec) } return nil diff --git a/cmd/labs/project/project.go b/cmd/labs/project/project.go index 11bf74c2991..a8228126bdf 100644 --- a/cmd/labs/project/project.go +++ b/cmd/labs/project/project.go @@ -11,11 +11,11 @@ import ( "time" "github.com/databricks/cli/cmd/labs/github" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/log" "github.com/databricks/cli/libs/python" "github.com/databricks/databricks-sdk-go/logger" - "github.com/fatih/color" "go.yaml.in/yaml/v3" "github.com/spf13/cobra" @@ -318,7 +318,7 @@ func (p *Project) checkUpdates(cmd *cobra.Command) error { } ago := time.Since(latest.PublishedAt) msg := "[UPGRADE ADVISED] Newer %s version was released %s ago. Please run `databricks labs upgrade %s` to upgrade: %s -> %s" - cmd.PrintErrln(color.YellowString(msg, p.Name, p.timeAgo(ago), p.Name, installed.Version, latest.Version)) + cmd.PrintErrln(cmdio.Yellow(ctx, fmt.Sprintf(msg, p.Name, p.timeAgo(ago), p.Name, installed.Version, latest.Version))) return nil } diff --git a/experimental/aitools/cmd/install.go b/experimental/aitools/cmd/install.go index b6e87d68b1e..8e95e511cf5 100644 --- a/experimental/aitools/cmd/install.go +++ b/experimental/aitools/cmd/install.go @@ -9,7 +9,6 @@ import ( "github.com/databricks/cli/experimental/aitools/lib/agents" "github.com/databricks/cli/experimental/aitools/lib/installer" "github.com/databricks/cli/libs/cmdio" - "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -141,7 +140,7 @@ func filterProjectScopeAgents(detected []*agents.Agent) []*agents.Agent { // printNoAgentsMessage prints the "no agents detected" message. func printNoAgentsMessage(ctx context.Context) { - cmdio.LogString(ctx, color.YellowString("No supported coding agents detected.")) + cmdio.LogString(ctx, cmdio.Yellow(ctx, "No supported coding agents detected.")) cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity") cmdio.LogString(ctx, "Please install at least one coding agent first.") diff --git a/experimental/aitools/lib/installer/installer.go b/experimental/aitools/lib/installer/installer.go index 982df0c1631..53285f6ffc9 100644 --- a/experimental/aitools/lib/installer/installer.go +++ b/experimental/aitools/lib/installer/installer.go @@ -19,7 +19,6 @@ import ( "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/log" - "github.com/fatih/color" "golang.org/x/mod/semver" ) @@ -304,7 +303,7 @@ func PrintInstallingFor(ctx context.Context, targetAgents []*agents.Agent) { } func printNoAgentsDetected(ctx context.Context) { - cmdio.LogString(ctx, color.YellowString("No supported coding agents detected.")) + cmdio.LogString(ctx, cmdio.Yellow(ctx, "No supported coding agents detected.")) cmdio.LogString(ctx, "") cmdio.LogString(ctx, "Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity") cmdio.LogString(ctx, "Please install at least one coding agent first.") diff --git a/experimental/ssh/internal/client/client.go b/experimental/ssh/internal/client/client.go index df7a3edd3db..5ab096d2929 100644 --- a/experimental/ssh/internal/client/client.go +++ b/experimental/ssh/internal/client/client.go @@ -33,7 +33,6 @@ import ( "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/jobs" "github.com/databricks/databricks-sdk-go/service/workspace" - "github.com/fatih/color" "github.com/gorilla/websocket" ) @@ -228,7 +227,7 @@ func Run(ctx context.Context, client *databricks.WorkspaceClient, opts ClientOpt if !opts.ProxyMode { cmdio.LogString(ctx, fmt.Sprintf("Connecting to %s...", sessionID)) if opts.IsServerlessMode() && opts.Accelerator == "" { - cmdio.LogString(ctx, color.YellowString("WARNING: serverless compute without an accelerator is in private preview. If you are not enrolled, this command will likely time out with an error. Contact your Databricks account team to enroll.")) + cmdio.LogString(ctx, cmdio.Yellow(ctx, "WARNING: serverless compute without an accelerator is in private preview. If you are not enrolled, this command will likely time out with an error. Contact your Databricks account team to enroll.")) } } @@ -314,7 +313,7 @@ func Run(ctx context.Context, client *databricks.WorkspaceClient, opts ClientOpt if err != nil { if opts.IsServerlessMode() && opts.Accelerator == "" && errors.Is(err, errServerMetadata) { return fmt.Errorf("failed to ensure that ssh server is running: %w\n\n"+ - color.YellowString("This may be because serverless compute without an accelerator is in private preview.\nContact your Databricks account team to enroll."), err) + cmdio.Yellow(ctx, "This may be because serverless compute without an accelerator is in private preview.\nContact your Databricks account team to enroll."), err) } return fmt.Errorf("failed to ensure that ssh server is running: %w", err) } diff --git a/go.mod b/go.mod index 20d43523dc7..bbb94e396da 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,6 @@ require ( github.com/charmbracelet/huh v1.0.0 // MIT github.com/charmbracelet/lipgloss v1.1.0 // MIT github.com/databricks/databricks-sdk-go v0.128.0 // Apache-2.0 - github.com/fatih/color v1.19.0 // MIT github.com/google/jsonschema-go v0.4.3 // MIT github.com/google/uuid v1.6.0 // BSD-3-Clause github.com/gorilla/mux v1.8.1 // BSD-3-Clause diff --git a/go.sum b/go.sum index 4bbce05ef27..0bbab194b27 100644 --- a/go.sum +++ b/go.sum @@ -88,8 +88,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= -github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= diff --git a/libs/apps/logstream/formatter.go b/libs/apps/logstream/formatter.go index ad1b19543fc..fa317f57af2 100644 --- a/libs/apps/logstream/formatter.go +++ b/libs/apps/logstream/formatter.go @@ -1,13 +1,14 @@ package logstream import ( + "context" "encoding/json" "fmt" "strings" "time" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/flags" - "github.com/fatih/color" ) // wsEntry represents a structured log entry from the websocket stream. @@ -38,28 +39,31 @@ func newLogFormatter(colorize bool, outputFormat flags.Output) *logFormatter { } // FormatEntry formats a structured log entry for output. -func (f *logFormatter) FormatEntry(entry *wsEntry) string { +func (f *logFormatter) FormatEntry(ctx context.Context, entry *wsEntry) string { if f.outputFormat == flags.OutputJSON { return f.formatEntryJSON(entry) } - return f.formatEntryText(entry) + return f.formatEntryText(ctx, entry) } // formatEntryText formats a structured log entry as human-readable text. -func (f *logFormatter) formatEntryText(entry *wsEntry) string { +func (f *logFormatter) formatEntryText(ctx context.Context, entry *wsEntry) string { timestamp := formatTimestamp(entry.Timestamp) source := strings.ToUpper(entry.Source) message := strings.TrimRight(entry.Message, "\r\n") if f.colorize { - timestamp = color.HiBlackString(timestamp) - source = color.HiBlueString(source) + timestamp = cmdio.HiBlack(ctx, timestamp) + source = cmdio.HiBlue(ctx, source) } return fmt.Sprintf("%s [%s] %s", timestamp, source, message) } // formatEntryJSON formats a structured log entry as JSON (NDJSON line). +// On marshal failure it falls back to the plain text path; that fallback is +// uncolored because we have no ctx at that point and JSON output is never +// piped to a TTY-colored renderer anyway. func (f *logFormatter) formatEntryJSON(entry *wsEntry) string { normalized := wsEntry{ Source: strings.ToUpper(entry.Source), @@ -68,7 +72,11 @@ func (f *logFormatter) formatEntryJSON(entry *wsEntry) string { } data, err := json.Marshal(normalized) if err != nil { - return f.formatEntryText(entry) + return fmt.Sprintf("%s [%s] %s", + formatTimestamp(entry.Timestamp), + strings.ToUpper(entry.Source), + strings.TrimRight(entry.Message, "\r\n"), + ) } return string(data) } diff --git a/libs/apps/logstream/formatter_test.go b/libs/apps/logstream/formatter_test.go index d470de43fbb..ff91d0b27d3 100644 --- a/libs/apps/logstream/formatter_test.go +++ b/libs/apps/logstream/formatter_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "testing" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/flags" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -11,10 +12,11 @@ import ( func TestFormatter_FormatEntry(t *testing.T) { entry := &wsEntry{Source: "app", Timestamp: 1705315800.0, Message: "hello world\n"} + ctx := cmdio.MockDiscard(t.Context()) t.Run("json output", func(t *testing.T) { jsonFormatter := newLogFormatter(false, flags.OutputJSON) - output := jsonFormatter.FormatEntry(entry) + output := jsonFormatter.FormatEntry(ctx, entry) var parsed wsEntry require.NoError(t, json.Unmarshal([]byte(output), &parsed)) @@ -27,7 +29,7 @@ func TestFormatter_FormatEntry(t *testing.T) { t.Run("text output", func(t *testing.T) { textFormatter := newLogFormatter(false, flags.OutputText) - output := textFormatter.FormatEntry(entry) + output := textFormatter.FormatEntry(ctx, entry) assert.Contains(t, output, "[APP]") assert.Contains(t, output, "hello world") diff --git a/libs/apps/logstream/streamer.go b/libs/apps/logstream/streamer.go index 81624bedb9c..5cbfc87ede9 100644 --- a/libs/apps/logstream/streamer.go +++ b/libs/apps/logstream/streamer.go @@ -251,7 +251,7 @@ func (s *logStreamer) consume(ctx context.Context, conn *websocket.Conn) (retErr continue } - line := s.formatMessage(message) + line := s.formatMessage(ctx, message) if line == "" { continue } @@ -261,7 +261,7 @@ func (s *logStreamer) consume(ctx context.Context, conn *websocket.Conn) (retErr } } -func (s *logStreamer) formatMessage(message []byte) string { +func (s *logStreamer) formatMessage(ctx context.Context, message []byte) string { entry, err := parseLogEntry(message) if err != nil { return s.formatter.FormatPlain(message) @@ -272,7 +272,7 @@ func (s *logStreamer) formatMessage(message []byte) string { return "" } } - return s.formatter.FormatEntry(entry) + return s.formatter.FormatEntry(ctx, entry) } func (s *logStreamer) ensureToken(ctx context.Context) error { diff --git a/libs/apps/logstream/streamer_test.go b/libs/apps/logstream/streamer_test.go index 20d12227386..cde5db8c94c 100644 --- a/libs/apps/logstream/streamer_test.go +++ b/libs/apps/logstream/streamer_test.go @@ -14,8 +14,8 @@ import ( "testing" "time" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/cli/libs/flags" - "github.com/fatih/color" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -221,19 +221,18 @@ func TestLogStreamerFiltersSources(t *testing.T) { } func TestFormatLogEntryColorizesWhenEnabled(t *testing.T) { - original := color.NoColor - color.NoColor = false - defer func() { color.NoColor = original }() - entry := &wsEntry{Source: "app", Timestamp: 1, Message: "hello\n"} + ttyCtx, _ := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true}) + plainCtx := cmdio.MockDiscard(t.Context()) + colorFormatter := newLogFormatter(true, flags.OutputText) - colored := colorFormatter.FormatEntry(entry) + colored := colorFormatter.FormatEntry(ttyCtx, entry) assert.Contains(t, colored, "\x1b[") - assert.Contains(t, colored, fmt.Sprintf("[%s]", color.HiBlueString("APP"))) + assert.Contains(t, colored, fmt.Sprintf("[%s]", cmdio.HiBlue(ttyCtx, "APP"))) plainFormatter := newLogFormatter(false, flags.OutputText) - plain := plainFormatter.FormatEntry(entry) + plain := plainFormatter.FormatEntry(plainCtx, entry) assert.NotContains(t, plain, "\x1b[") assert.Contains(t, plain, "[APP]") } diff --git a/libs/cmdio/color.go b/libs/cmdio/color.go new file mode 100644 index 00000000000..4066b30f75c --- /dev/null +++ b/libs/cmdio/color.go @@ -0,0 +1,90 @@ +package cmdio + +import ( + "context" + "fmt" + "text/template" +) + +// SGR (Select Graphic Rendition) escapes; see +// https://en.wikipedia.org/wiki/ANSI_escape_code#SGR +const ( + ansiReset = "\x1b[0m" + ansiBold = "\x1b[1m" + ansiItalic = "\x1b[3m" + ansiRed = "\x1b[31m" + ansiGreen = "\x1b[32m" + ansiYellow = "\x1b[33m" + ansiBlue = "\x1b[34m" + ansiMagenta = "\x1b[35m" + ansiCyan = "\x1b[36m" + ansiHiBlack = "\x1b[90m" + ansiHiBlue = "\x1b[94m" + ansiBoldGreen = "\x1b[32;1m" + ansiBoldBlue = "\x1b[34;1m" +) + +// colorEnabled reports whether ctx permits colorized output. Returns false +// when no cmdIO is attached so the helpers can be called from contexts +// without one (e.g. mutator unit tests) without panicking. +func colorEnabled(ctx context.Context) bool { + c, ok := ctx.Value(cmdIOKey).(*cmdIO) + if !ok { + return false + } + return c.capabilities.SupportsStdoutColor() +} + +func render(ctx context.Context, code, msg string) string { + if !colorEnabled(ctx) { + return msg + } + return code + msg + ansiReset +} + +// Red renders msg in red. +func Red(ctx context.Context, msg string) string { return render(ctx, ansiRed, msg) } + +// Green renders msg in green. +func Green(ctx context.Context, msg string) string { return render(ctx, ansiGreen, msg) } + +// Yellow renders msg in yellow. +func Yellow(ctx context.Context, msg string) string { return render(ctx, ansiYellow, msg) } + +// Blue renders msg in blue. +func Blue(ctx context.Context, msg string) string { return render(ctx, ansiBlue, msg) } + +// Cyan renders msg in cyan. +func Cyan(ctx context.Context, msg string) string { return render(ctx, ansiCyan, msg) } + +// HiBlack renders msg in bright black (gray). +func HiBlack(ctx context.Context, msg string) string { return render(ctx, ansiHiBlack, msg) } + +// HiBlue renders msg in bright blue. +func HiBlue(ctx context.Context, msg string) string { return render(ctx, ansiHiBlue, msg) } + +// RenderFuncMap returns a template.FuncMap with color helpers bound to ctx. +// Templates use the printf-style signature (`{{ green "%d" .JobId }}`) so the +// helpers accept a format string + args. +func RenderFuncMap(ctx context.Context) template.FuncMap { + return template.FuncMap{ + "red": templateColor(ctx, ansiRed), + "green": templateColor(ctx, ansiGreen), + "blue": templateColor(ctx, ansiBlue), + "yellow": templateColor(ctx, ansiYellow), + "magenta": templateColor(ctx, ansiMagenta), + "cyan": templateColor(ctx, ansiCyan), + "bold": templateColor(ctx, ansiBold), + "italic": templateColor(ctx, ansiItalic), + } +} + +func templateColor(ctx context.Context, code string) func(string, ...any) string { + return func(format string, a ...any) string { + msg := format + if len(a) > 0 { + msg = fmt.Sprintf(format, a...) + } + return render(ctx, code, msg) + } +} diff --git a/libs/cmdio/color_test.go b/libs/cmdio/color_test.go new file mode 100644 index 00000000000..54df1859827 --- /dev/null +++ b/libs/cmdio/color_test.go @@ -0,0 +1,75 @@ +package cmdio_test + +import ( + "context" + "testing" + + "github.com/databricks/cli/libs/cmdio" + "github.com/stretchr/testify/assert" +) + +func ttyContext(t *testing.T) context.Context { + t.Helper() + ctx, _ := cmdio.SetupTest(t.Context(), cmdio.TestOptions{PromptSupported: true}) + return ctx +} + +func noColorContext(t *testing.T) context.Context { + t.Helper() + return cmdio.MockDiscard(t.Context()) +} + +func TestColorHelpersEmitSGRWhenEnabled(t *testing.T) { + ctx := ttyContext(t) + + cases := []struct { + name string + got string + want string + }{ + {"Red", cmdio.Red(ctx, "hello"), "\x1b[31mhello\x1b[0m"}, + {"Green", cmdio.Green(ctx, "ok"), "\x1b[32mok\x1b[0m"}, + {"Yellow", cmdio.Yellow(ctx, "warn"), "\x1b[33mwarn\x1b[0m"}, + {"Blue", cmdio.Blue(ctx, "info"), "\x1b[34minfo\x1b[0m"}, + {"Cyan", cmdio.Cyan(ctx, "debug"), "\x1b[36mdebug\x1b[0m"}, + {"HiBlack", cmdio.HiBlack(ctx, "dim"), "\x1b[90mdim\x1b[0m"}, + {"HiBlue", cmdio.HiBlue(ctx, "APP"), "\x1b[94mAPP\x1b[0m"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, c.got) + }) + } +} + +func TestColorHelpersReturnPlainWhenDisabled(t *testing.T) { + ctx := noColorContext(t) + + assert.Equal(t, "hello", cmdio.Red(ctx, "hello")) + assert.Equal(t, "warn", cmdio.Yellow(ctx, "warn")) +} + +// Mutator/library code paths can run with a bare context (e.g. inside a unit +// test that calls bundle.Apply with t.Context()). The helpers must degrade +// to plain text rather than panic. +func TestColorHelpersDoNotPanicWithoutCmdIO(t *testing.T) { + ctx := t.Context() + + assert.Equal(t, "hello", cmdio.Red(ctx, "hello")) + assert.Equal(t, "ok", cmdio.Green(ctx, "ok")) + assert.Equal(t, "label: ", cmdio.Cyan(ctx, "label: ")) +} + +func TestRenderFuncMap(t *testing.T) { + ctx := ttyContext(t) + fm := cmdio.RenderFuncMap(ctx) + + for _, name := range []string{"red", "green", "blue", "yellow", "magenta", "cyan", "bold", "italic"} { + _, ok := fm[name].(func(string, ...any) string) + assert.True(t, ok, "FuncMap missing %q or wrong signature", name) + } + + red := fm["red"].(func(string, ...any) string) + assert.Equal(t, "\x1b[31mhi 1\x1b[0m", red("%s %d", "hi", 1)) + assert.Equal(t, "\x1b[31mhi\x1b[0m", red("hi")) +} diff --git a/libs/cmdio/jsoncolor.go b/libs/cmdio/jsoncolor.go index ba79ef8fecf..97b2cc1fff4 100644 --- a/libs/cmdio/jsoncolor.go +++ b/libs/cmdio/jsoncolor.go @@ -6,18 +6,6 @@ import ( "fmt" ) -// SGR (Select Graphic Rendition) escapes; see -// https://en.wikipedia.org/wiki/ANSI_escape_code#SGR -const ( - ansiReset = "\x1b[0m" - ansiGreen = "\x1b[32m" - ansiBoldGreen = "\x1b[32;1m" - ansiRed = "\x1b[31m" - ansiCyan = "\x1b[36m" - ansiMagenta = "\x1b[35m" - ansiBoldBlue = "\x1b[34;1m" -) - // marshalJSON returns indented JSON, optionally colorized for TTY output. func marshalJSON(v any, colorize bool) ([]byte, error) { b, err := json.MarshalIndent(v, "", " ") diff --git a/libs/cmdio/paged_template.go b/libs/cmdio/paged_template.go index a579fa48afd..f0eccc71f6c 100644 --- a/libs/cmdio/paged_template.go +++ b/libs/cmdio/paged_template.go @@ -14,8 +14,8 @@ import ( ) // ansiCSIPattern matches ANSI SGR escape sequences so colored cells -// aren't counted toward column widths. github.com/fatih/color emits CSI -// ... m, which is all our templates use. +// aren't counted toward column widths. The color helpers in this package +// emit CSI ... m, which is all our templates produce. var ansiCSIPattern = regexp.MustCompile("\x1b\\[[0-9;]*m") // renderIteratorPagedTemplate pages an iterator through the template @@ -89,11 +89,12 @@ func renderIteratorPagedTemplateCore[T any]( // Header and row templates must be separate *template.Template // instances: Parse replaces the receiver's body in place, so sharing // one makes the second Parse stomp the first. - headerT, err := template.New("header").Funcs(renderFuncMap).Parse(headerTemplate) + fm := renderFuncMap(ctx) + headerT, err := template.New("header").Funcs(fm).Parse(headerTemplate) if err != nil { return err } - rowT, err := template.New("row").Funcs(renderFuncMap).Parse(tmpl) + rowT, err := template.New("row").Funcs(fm).Parse(tmpl) if err != nil { return err } diff --git a/libs/cmdio/paged_template_test.go b/libs/cmdio/paged_template_test.go index 24daeb45985..013f9ff0c58 100644 --- a/libs/cmdio/paged_template_test.go +++ b/libs/cmdio/paged_template_test.go @@ -77,7 +77,7 @@ func countContentLines(s string) int { } func TestPagedTemplateDrainsFullIterator(t *testing.T) { - out := pagedOutput(t, t.Context(), &numberIterator{n: 23}, "", "{{range .}}{{.}}\n{{end}}", 5) + out := pagedOutput(t, MockDiscard(t.Context()), &numberIterator{n: 23}, "", "{{range .}}{{.}}\n{{end}}", 5) assert.Equal(t, 23, countContentLines(out)) for i := 1; i <= 23; i++ { assert.Contains(t, out, strconv.Itoa(i)) @@ -85,20 +85,20 @@ func TestPagedTemplateDrainsFullIterator(t *testing.T) { } func TestPagedTemplateRespectsLimit(t *testing.T) { - ctx := WithLimit(t.Context(), 7) + ctx := WithLimit(MockDiscard(t.Context()), 7) out := pagedOutput(t, ctx, &numberIterator{n: 200}, "", "{{range .}}{{.}}\n{{end}}", 5) assert.Equal(t, 7, countContentLines(out)) } func TestPagedTemplatePrintsHeaderOnce(t *testing.T) { - out := pagedOutput(t, t.Context(), &numberIterator{n: 8}, "ID", "{{range .}}{{.}}\n{{end}}", 3) + out := pagedOutput(t, MockDiscard(t.Context()), &numberIterator{n: 8}, "ID", "{{range .}}{{.}}\n{{end}}", 3) assert.Equal(t, 1, strings.Count(out, "ID")) } func TestPagedTemplatePropagatesFetchError(t *testing.T) { var buf bytes.Buffer err := renderIteratorPagedTemplateCore( - t.Context(), + MockDiscard(t.Context()), &numberIterator{n: 100, err: errors.New("boom")}, strings.NewReader(""), &buf, @@ -111,7 +111,7 @@ func TestPagedTemplatePropagatesFetchError(t *testing.T) { } func TestPagedTemplateRendersHeaderAndRows(t *testing.T) { - out := pagedOutput(t, t.Context(), &numberIterator{n: 6}, "ID\tName", "{{range .}}{{.}}\titem-{{.}}\n{{end}}", 100) + out := pagedOutput(t, MockDiscard(t.Context()), &numberIterator{n: 6}, "ID\tName", "{{range .}}{{.}}\titem-{{.}}\n{{end}}", 100) assert.Contains(t, out, "ID") assert.Contains(t, out, "Name") for i := 1; i <= 6; i++ { @@ -125,7 +125,7 @@ func TestPagedTemplateEmptyIteratorStillFlushesHeader(t *testing.T) { defer pw.Close() var out bytes.Buffer require.NoError(t, renderIteratorPagedTemplateCore( - t.Context(), + MockDiscard(t.Context()), &numberIterator{n: 0}, pr, &out, @@ -141,7 +141,7 @@ func TestPagedTemplateEmptyIteratorStillFlushesHeader(t *testing.T) { func TestPagedTemplateColumnsStableAcrossBatches(t *testing.T) { it := &numberIterator{n: 6} tmpl := "{{range .}}col-{{.}}\tval\n{{end}}" - out := pagedOutput(t, t.Context(), it, "", tmpl, 3) + out := pagedOutput(t, MockDiscard(t.Context()), it, "", tmpl, 3) lines := strings.Split(strings.TrimRight(out, "\n"), "\n") var dataRows []string for _, l := range lines { @@ -167,14 +167,14 @@ func TestPagedTemplateMatchesNonPagedForSmallList(t *testing.T) { var expected bytes.Buffer refIter := listing.Iterator[int](&numberIterator{n: rows}) - require.NoError(t, renderWithTemplate(t.Context(), newIteratorRenderer(refIter), flags.OutputText, &expected, "", tmpl)) + require.NoError(t, renderWithTemplate(MockDiscard(t.Context()), newIteratorRenderer(refIter), flags.OutputText, &expected, "", tmpl)) pagedIter := listing.Iterator[int](&numberIterator{n: rows}) var actual bytes.Buffer pr, pw := io.Pipe() defer pw.Close() require.NoError(t, renderIteratorPagedTemplateCore( - t.Context(), + MockDiscard(t.Context()), pagedIter, pr, &actual, diff --git a/libs/cmdio/pager_test.go b/libs/cmdio/pager_test.go index 0153d5eac06..645b26cbd04 100644 --- a/libs/cmdio/pager_test.go +++ b/libs/cmdio/pager_test.go @@ -14,11 +14,13 @@ import ( func newTestPager(t *testing.T, iter listing.Iterator[int], pageSize int) *pagerModel[int] { t.Helper() - rowT, err := template.New("row").Funcs(renderFuncMap).Parse("{{range .}}{{.}}\n{{end}}") + ctx := MockDiscard(t.Context()) + fm := renderFuncMap(ctx) + rowT, err := template.New("row").Funcs(fm).Parse("{{range .}}{{.}}\n{{end}}") require.NoError(t, err) - headerT, err := template.New("header").Funcs(renderFuncMap).Parse("") + headerT, err := template.New("header").Funcs(fm).Parse("") require.NoError(t, err) - return newPagerModel(t.Context(), iter, &templatePager{ + return newPagerModel(ctx, iter, &templatePager{ headerT: headerT, rowT: rowT, }, pageSize, 0) diff --git a/libs/cmdio/render.go b/libs/cmdio/render.go index 72895d301d6..874db015c8b 100644 --- a/libs/cmdio/render.go +++ b/libs/cmdio/render.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "maps" "strings" "text/tabwriter" "text/template" @@ -16,7 +17,6 @@ import ( "github.com/databricks/cli/libs/diag" "github.com/databricks/cli/libs/flags" "github.com/databricks/databricks-sdk-go/listing" - "github.com/fatih/color" ) // Heredoc is the equivalent of compute.TrimLeadingWhitespace @@ -295,49 +295,12 @@ func RenderWithTemplate(ctx context.Context, v any, headerTemplate, template str return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, headerTemplate, template) } -var renderFuncMap = template.FuncMap{ - // we render colored output if stdout is TTY, otherwise we render text. - // in the future we'll check if we can explicitly check for stderr being - // a TTY - "header": color.BlueString, - "red": color.RedString, - "green": color.GreenString, - "blue": color.BlueString, - "yellow": color.YellowString, - "magenta": color.MagentaString, - "cyan": color.CyanString, - "bold": func(format string, a ...any) string { - return color.New(color.Bold).Sprintf(format, a...) - }, - "italic": func(format string, a ...any) string { - return color.New(color.Italic).Sprintf(format, a...) - }, +// staticTemplateFuncs are the ctx-independent helpers shared across every +// renderFuncMap call. +var staticTemplateFuncs = template.FuncMap{ "replace": strings.ReplaceAll, "join": strings.Join, - "sub": func(a, b int) int { - return a - b - }, - "bool": func(v bool) string { - if v { - return color.GreenString("YES") - } - return color.RedString("NO") - }, - "pretty_json": func(in string) (string, error) { - var tmp any - err := json.Unmarshal([]byte(in), &tmp) - if err != nil { - return "", err - } - // Mirror the other helpers in this map (red/green/etc.) by gating - // on fatih/color's global NoColor flag, which is set per-process - // based on stdout being a TTY. - b, err := marshalJSON(tmp, !color.NoColor) - if err != nil { - return "", err - } - return string(b), nil - }, + "sub": func(a, b int) int { return a - b }, "pretty_date": func(t time.Time) string { return t.Format("2006-01-02T15:04:05Z") }, @@ -368,9 +331,37 @@ var renderFuncMap = template.FuncMap{ }, } +// renderFuncMap returns the template helpers used by cmdio's rendering +// pipeline. Color helpers and the JSON pretty-printer depend on ctx; the +// rest are taken from staticTemplateFuncs. +func renderFuncMap(ctx context.Context) template.FuncMap { + fm := RenderFuncMap(ctx) + fm["header"] = fm["blue"] + fm["bool"] = func(v bool) string { + if v { + return Green(ctx, "YES") + } + return Red(ctx, "NO") + } + fm["pretty_json"] = func(in string) (string, error) { + var tmp any + err := json.Unmarshal([]byte(in), &tmp) + if err != nil { + return "", err + } + b, err := marshalJSON(tmp, colorEnabled(ctx)) + if err != nil { + return "", err + } + return string(b), nil + } + maps.Copy(fm, staticTemplateFuncs) + return fm +} + func renderUsingTemplate(ctx context.Context, r templateRenderer, w io.Writer, headerTmpl, tmpl string) error { tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) - base := template.New("command").Funcs(renderFuncMap) + base := template.New("command").Funcs(renderFuncMap(ctx)) if headerTmpl != "" { headerT, err := base.Parse(headerTmpl) if err != nil { @@ -446,13 +437,14 @@ const recommendationTemplate = `{{ "Recommendation" | blue }}: {{ .Summary }} func RenderDiagnostics(ctx context.Context, diags diag.Diagnostics) error { c := fromContext(ctx) - return renderDiagnostics(c.err, diags) + return renderDiagnostics(ctx, c.err, diags) } -func renderDiagnostics(out io.Writer, diags diag.Diagnostics) error { - errorT := template.Must(template.New("error").Funcs(renderFuncMap).Parse(errorTemplate)) - warningT := template.Must(template.New("warning").Funcs(renderFuncMap).Parse(warningTemplate)) - recommendationT := template.Must(template.New("recommendation").Funcs(renderFuncMap).Parse(recommendationTemplate)) +func renderDiagnostics(ctx context.Context, out io.Writer, diags diag.Diagnostics) error { + fm := renderFuncMap(ctx) + errorT := template.Must(template.New("error").Funcs(fm).Parse(errorTemplate)) + warningT := template.Must(template.New("warning").Funcs(fm).Parse(warningTemplate)) + recommendationT := template.Must(template.New("recommendation").Funcs(fm).Parse(recommendationTemplate)) // Print errors and warnings. for _, d := range diags { diff --git a/libs/cmdio/render_test.go b/libs/cmdio/render_test.go index 47ed38d2f5a..e4841957b75 100644 --- a/libs/cmdio/render_test.go +++ b/libs/cmdio/render_test.go @@ -169,7 +169,7 @@ var testCases = []testCase{ } // TestRenderJSONColorGate verifies defaultRenderer.renderJson honors the -// stdout TTY/color capabilities directly, independent of fatih/color globals. +// stdout TTY/color capabilities directly. func TestRenderJSONColorGate(t *testing.T) { tests := []struct { name string diff --git a/libs/databrickscfg/cfgpickers/clusters.go b/libs/databrickscfg/cfgpickers/clusters.go index 38253d20ecb..1bedf0dcde7 100644 --- a/libs/databrickscfg/cfgpickers/clusters.go +++ b/libs/databrickscfg/cfgpickers/clusters.go @@ -11,7 +11,6 @@ import ( "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/iam" - "github.com/fatih/color" "github.com/manifoldco/promptui" "golang.org/x/mod/semver" ) @@ -66,6 +65,9 @@ var ErrNoCompatibleClusters = errors.New("no compatible clusters found") type compatibleCluster struct { compute.ClusterDetails versionName string + // renderedState caches the colorized ClusterDetails.State for display in + // promptui templates, which can't access ctx-bound color helpers. + renderedState string } func (v compatibleCluster) Access() string { @@ -85,15 +87,7 @@ func (v compatibleCluster) Runtime() string { } func (v compatibleCluster) State() string { - state := v.ClusterDetails.State - switch state { - case compute.StateRunning, compute.StateResizing: - return color.GreenString(state.String()) - case compute.StateError, compute.StateTerminated, compute.StateTerminating, compute.StateUnknown: - return color.RedString(state.String()) - default: - return color.BlueString(state.String()) - } + return v.renderedState } type clusterFilter func(cluster *compute.ClusterDetails, me *iam.User) bool @@ -170,9 +164,19 @@ func loadInteractiveClusters(ctx context.Context, w *databricks.WorkspaceClient, if skip { continue } + var renderedState string + switch cluster.State { + case compute.StateRunning, compute.StateResizing: + renderedState = cmdio.Green(ctx, cluster.State.String()) + case compute.StateError, compute.StateTerminated, compute.StateTerminating, compute.StateUnknown: + renderedState = cmdio.Red(ctx, cluster.State.String()) + default: + renderedState = cmdio.Blue(ctx, cluster.State.String()) + } compatible = append(compatible, compatibleCluster{ ClusterDetails: cluster, versionName: versions[cluster.SparkVersion], + renderedState: renderedState, }) } return compatible, nil diff --git a/libs/databrickscfg/cfgpickers/warehouses.go b/libs/databrickscfg/cfgpickers/warehouses.go index c08ec21d2c6..9f0c617e34b 100644 --- a/libs/databrickscfg/cfgpickers/warehouses.go +++ b/libs/databrickscfg/cfgpickers/warehouses.go @@ -14,7 +14,6 @@ import ( "github.com/databricks/databricks-sdk-go/config" "github.com/databricks/databricks-sdk-go/httpclient" "github.com/databricks/databricks-sdk-go/service/sql" - "github.com/fatih/color" ) var ErrNoCompatibleWarehouses = errors.New("no compatible warehouses") @@ -51,11 +50,11 @@ func AskForWarehouse(ctx context.Context, w *databricks.WorkspaceClient, filters var state string switch warehouse.State { case sql.StateRunning: - state = color.GreenString(warehouse.State.String()) + state = cmdio.Green(ctx, warehouse.State.String()) case sql.StateStopped, sql.StateDeleted, sql.StateStopping, sql.StateDeleting: - state = color.RedString(warehouse.State.String()) + state = cmdio.Red(ctx, warehouse.State.String()) default: - state = color.BlueString(warehouse.State.String()) + state = cmdio.Blue(ctx, warehouse.State.String()) } visibleTouser := fmt.Sprintf("%s (%s %s)", warehouse.Name, state, warehouse.WarehouseType) names[visibleTouser] = warehouse.Id @@ -203,9 +202,9 @@ func SelectWarehouse(ctx context.Context, w *databricks.WorkspaceClient, descrip for _, warehouse := range warehouses { var icon string if warehouse.State == sql.StateRunning { - icon = color.GreenString("●") + icon = cmdio.Green(ctx, "●") } else { - icon = color.HiBlackString("○") + icon = cmdio.HiBlack(ctx, "○") } // Show type info in gray @@ -214,9 +213,9 @@ func SelectWarehouse(ctx context.Context, w *databricks.WorkspaceClient, descrip typeInfo = "serverless" } - name := fmt.Sprintf("%s %s %s", icon, warehouse.Name, color.HiBlackString(typeInfo)) + name := fmt.Sprintf("%s %s %s", icon, warehouse.Name, cmdio.HiBlack(ctx, typeInfo)) if warehouse.Id == defaultId { - name += color.HiBlackString(" [DEFAULT]") + name += cmdio.HiBlack(ctx, " [DEFAULT]") } items = append(items, cmdio.Tuple{Name: name, Id: warehouse.Id}) } diff --git a/libs/databrickscfg/cfgpickers/warehouses_test.go b/libs/databrickscfg/cfgpickers/warehouses_test.go index 90b076df36b..818c8f6a81a 100644 --- a/libs/databrickscfg/cfgpickers/warehouses_test.go +++ b/libs/databrickscfg/cfgpickers/warehouses_test.go @@ -3,6 +3,7 @@ package cfgpickers import ( "testing" + "github.com/databricks/cli/libs/cmdio" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/qa" "github.com/databricks/databricks-sdk-go/service/sql" @@ -34,7 +35,7 @@ func TestFirstCompatibleWarehouse(t *testing.T) { defer server.Close() w := databricks.Must(databricks.NewWorkspaceClient((*databricks.Config)(cfg))) - ctx := t.Context() + ctx := cmdio.MockDiscard(t.Context()) clusterID, err := AskForWarehouse(ctx, w, WithWarehouseTypes(sql.EndpointInfoWarehouseTypePro)) require.NoError(t, err) assert.Equal(t, "efg-id", clusterID) @@ -59,7 +60,7 @@ func TestNoCompatibleWarehouses(t *testing.T) { defer server.Close() w := databricks.Must(databricks.NewWorkspaceClient((*databricks.Config)(cfg))) - ctx := t.Context() + ctx := cmdio.MockDiscard(t.Context()) _, err := AskForWarehouse(ctx, w, WithWarehouseTypes(sql.EndpointInfoWarehouseTypePro)) assert.Equal(t, ErrNoCompatibleWarehouses, err) } diff --git a/libs/log/handler/colors.go b/libs/log/handler/colors.go index a1b8e84917b..f223ac1b04b 100644 --- a/libs/log/handler/colors.go +++ b/libs/log/handler/colors.go @@ -1,14 +1,31 @@ package handler -import "github.com/fatih/color" +const ( + ansiReset = "\x1b[0m" + ansiBlackBold = "\x1b[30;1m" + ansiWhite = "\x1b[37m" + ansiFaint = "\x1b[2m" + ansiRed = "\x1b[31m" + ansiYellow = "\x1b[33m" + ansiBlue = "\x1b[34m" + ansiMagenta = "\x1b[35m" + ansiCyan = "\x1b[36m" +) -// ttyColors is a slice of colors that can be enabled or disabled. -// This adds one level of indirection to the colors such that they -// can be easily be enabled or disabled together, regardless of -// global settings in the color package. -type ttyColors []*color.Color +// ttyStyle is an SGR escape prefix that wraps a string with a trailing reset. +// An empty value emits the input unchanged so the handler can disable colors +// by zeroing the palette. +type ttyStyle string + +func (s ttyStyle) Render(msg string) string { + if s == "" { + return msg + } + return string(s) + msg + ansiReset +} + +type ttyColors []ttyStyle -// ttyColor is an enum for the colors in ttyColors. type ttyColor int const ( @@ -29,28 +46,20 @@ const ( ) func newColors(enable bool) ttyColors { - ttyColors := make(ttyColors, ttyColorLevelLast) - ttyColors[ttyColorInvalid] = color.New(color.FgWhite) - ttyColors[ttyColorTime] = color.New(color.FgBlack, color.Bold) - ttyColors[ttyColorMessage] = color.New(color.Reset) - ttyColors[ttyColorAttrKey] = color.New(color.Faint) - ttyColors[ttyColorAttrSeparator] = color.New(color.Faint) - ttyColors[ttyColorAttrValue] = color.New(color.Reset) - ttyColors[ttyColorLevelTrace] = color.New(color.FgMagenta) - ttyColors[ttyColorLevelDebug] = color.New(color.FgCyan) - ttyColors[ttyColorLevelInfo] = color.New(color.FgBlue) - ttyColors[ttyColorLevelWarn] = color.New(color.FgYellow) - ttyColors[ttyColorLevelError] = color.New(color.FgRed) - - if enable { - for _, color := range ttyColors { - color.EnableColor() - } - } else { - for _, color := range ttyColors { - color.DisableColor() - } + if !enable { + return make(ttyColors, ttyColorLevelLast) } - - return ttyColors + colors := make(ttyColors, ttyColorLevelLast) + colors[ttyColorInvalid] = ansiWhite + colors[ttyColorTime] = ansiBlackBold + colors[ttyColorMessage] = ansiReset + colors[ttyColorAttrKey] = ansiFaint + colors[ttyColorAttrSeparator] = ansiFaint + colors[ttyColorAttrValue] = ansiReset + colors[ttyColorLevelTrace] = ansiMagenta + colors[ttyColorLevelDebug] = ansiCyan + colors[ttyColorLevelInfo] = ansiBlue + colors[ttyColorLevelWarn] = ansiYellow + colors[ttyColorLevelError] = ansiRed + return colors } diff --git a/libs/log/handler/colors_test.go b/libs/log/handler/colors_test.go index aa042fb0bbd..b57ea9b3a10 100644 --- a/libs/log/handler/colors_test.go +++ b/libs/log/handler/colors_test.go @@ -6,20 +6,20 @@ import ( ) func showColors(t *testing.T, colors ttyColors) { - t.Log(colors[ttyColorInvalid].Sprint("invalid")) - t.Log(colors[ttyColorTime].Sprint("time")) + t.Log(colors[ttyColorInvalid].Render("invalid")) + t.Log(colors[ttyColorTime].Render("time")) t.Log( fmt.Sprint( - colors[ttyColorAttrKey].Sprint("key"), - colors[ttyColorAttrSeparator].Sprint("="), - colors[ttyColorAttrValue].Sprint("value"), + colors[ttyColorAttrKey].Render("key"), + colors[ttyColorAttrSeparator].Render("="), + colors[ttyColorAttrValue].Render("value"), ), ) - t.Log(colors[ttyColorLevelTrace].Sprint("trace")) - t.Log(colors[ttyColorLevelDebug].Sprint("debug")) - t.Log(colors[ttyColorLevelInfo].Sprint("info")) - t.Log(colors[ttyColorLevelWarn].Sprint("warn")) - t.Log(colors[ttyColorLevelError].Sprint("error")) + t.Log(colors[ttyColorLevelTrace].Render("trace")) + t.Log(colors[ttyColorLevelDebug].Render("debug")) + t.Log(colors[ttyColorLevelInfo].Render("info")) + t.Log(colors[ttyColorLevelWarn].Render("warn")) + t.Log(colors[ttyColorLevelError].Render("error")) } func TestTTYColorsEnabled(t *testing.T) { diff --git a/libs/log/handler/friendly.go b/libs/log/handler/friendly.go index 354675edc30..6bdb85db4e8 100644 --- a/libs/log/handler/friendly.go +++ b/libs/log/handler/friendly.go @@ -62,11 +62,11 @@ func NewFriendlyHandler(out io.Writer, opts *Options) slog.Handler { } func (h *friendlyHandler) sprint(color ttyColor, args ...any) string { - return h.ttyColors[color].Sprint(args...) + return h.ttyColors[color].Render(fmt.Sprint(args...)) } func (h *friendlyHandler) sprintf(color ttyColor, format string, args ...any) string { - return h.ttyColors[color].Sprintf(format, args...) + return h.ttyColors[color].Render(fmt.Sprintf(format, args...)) } func (h *friendlyHandler) coloredLevel(r slog.Record) string {