mirror of https://github.com/databricks/cli.git
468 lines
12 KiB
Go
468 lines
12 KiB
Go
package cmdio
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/databricks/cli/libs/diag"
|
|
"github.com/databricks/cli/libs/flags"
|
|
"github.com/databricks/databricks-sdk-go/listing"
|
|
"github.com/fatih/color"
|
|
"github.com/nwidger/jsoncolor"
|
|
)
|
|
|
|
// Heredoc is the equivalent of compute.TrimLeadingWhitespace
|
|
// (command-execution API helper from SDK), except it's more
|
|
// friendly to non-printable characters.
|
|
func Heredoc(tmpl string) (trimmed string) {
|
|
lines := strings.Split(tmpl, "\n")
|
|
leadingWhitespace := 1<<31 - 1
|
|
for _, line := range lines {
|
|
for pos, char := range line {
|
|
if char == ' ' || char == '\t' {
|
|
continue
|
|
}
|
|
// first non-whitespace character
|
|
if pos < leadingWhitespace {
|
|
leadingWhitespace = pos
|
|
}
|
|
// is not needed further
|
|
break
|
|
}
|
|
}
|
|
for i := 0; i < len(lines); i++ {
|
|
if lines[i] == "" || strings.TrimSpace(lines[i]) == "" {
|
|
continue
|
|
}
|
|
if len(lines[i]) < leadingWhitespace {
|
|
trimmed += lines[i] + "\n" // or not..
|
|
} else {
|
|
trimmed += lines[i][leadingWhitespace:] + "\n"
|
|
}
|
|
}
|
|
return strings.TrimSpace(trimmed)
|
|
}
|
|
|
|
// writeFlusher represents a buffered writer that can be flushed. This is useful when
|
|
// buffering writing a large number of resources (such as during a list API).
|
|
type writeFlusher interface {
|
|
io.Writer
|
|
Flush() error
|
|
}
|
|
|
|
type jsonRenderer interface {
|
|
// Render an object as JSON to the provided writeFlusher.
|
|
renderJson(context.Context, writeFlusher) error
|
|
}
|
|
|
|
type textRenderer interface {
|
|
// Render an object as text to the provided writeFlusher.
|
|
renderText(context.Context, io.Writer) error
|
|
}
|
|
|
|
type templateRenderer interface {
|
|
// Render an object using the provided template and write to the provided tabwriter.Writer.
|
|
renderTemplate(context.Context, *template.Template, *tabwriter.Writer) error
|
|
}
|
|
|
|
type readerRenderer struct {
|
|
reader io.Reader
|
|
}
|
|
|
|
func (r readerRenderer) renderText(_ context.Context, w io.Writer) error {
|
|
_, err := io.Copy(w, r.reader)
|
|
return err
|
|
}
|
|
|
|
type iteratorRenderer[T any] struct {
|
|
t listing.Iterator[T]
|
|
bufferSize int
|
|
}
|
|
|
|
func (ir iteratorRenderer[T]) getBufferSize() int {
|
|
if ir.bufferSize == 0 {
|
|
return 20
|
|
}
|
|
return ir.bufferSize
|
|
}
|
|
|
|
func (ir iteratorRenderer[T]) renderJson(ctx context.Context, w writeFlusher) error {
|
|
// Iterators are always rendered as a list of resources in JSON.
|
|
_, err := w.Write([]byte("[\n "))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := 0; ir.t.HasNext(ctx); i++ {
|
|
if i != 0 {
|
|
_, err = w.Write([]byte(",\n "))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
n, err := ir.t.Next(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := json.MarshalIndent(n, " ", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = w.Write(res)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if (i+1)%ir.getBufferSize() == 0 {
|
|
err = w.Flush()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
_, err = w.Write([]byte("\n]\n"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
func (ir iteratorRenderer[T]) renderTemplate(ctx context.Context, t *template.Template, w *tabwriter.Writer) error {
|
|
buf := make([]any, 0, ir.getBufferSize())
|
|
for i := 0; ir.t.HasNext(ctx); i++ {
|
|
n, err := ir.t.Next(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buf = append(buf, n)
|
|
if len(buf) == cap(buf) {
|
|
err = t.Execute(w, buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = w.Flush()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buf = buf[:0]
|
|
}
|
|
}
|
|
if len(buf) > 0 {
|
|
err := t.Execute(w, buf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
type defaultRenderer struct {
|
|
t any
|
|
}
|
|
|
|
func (d defaultRenderer) renderJson(_ context.Context, w writeFlusher) error {
|
|
pretty, err := fancyJSON(d.t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = w.Write(pretty)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = w.Write([]byte("\n"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return w.Flush()
|
|
}
|
|
|
|
func (d defaultRenderer) renderTemplate(_ context.Context, t *template.Template, w *tabwriter.Writer) error {
|
|
return t.Execute(w, d.t)
|
|
}
|
|
|
|
// Returns something implementing one of the following interfaces:
|
|
// - jsonRenderer
|
|
// - textRenderer
|
|
// - templateRenderer
|
|
func newRenderer(t any) any {
|
|
if r, ok := t.(io.Reader); ok {
|
|
return readerRenderer{reader: r}
|
|
}
|
|
return defaultRenderer{t: t}
|
|
}
|
|
|
|
func newIteratorRenderer[T any](i listing.Iterator[T]) iteratorRenderer[T] {
|
|
return iteratorRenderer[T]{t: i}
|
|
}
|
|
|
|
type bufferedFlusher struct {
|
|
w io.Writer
|
|
b *bytes.Buffer
|
|
}
|
|
|
|
func (b bufferedFlusher) Write(bs []byte) (int, error) {
|
|
return b.b.Write(bs)
|
|
}
|
|
|
|
func (b bufferedFlusher) Flush() error {
|
|
_, err := b.w.Write(b.b.Bytes())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.b.Reset()
|
|
return nil
|
|
}
|
|
|
|
func newBufferedFlusher(w io.Writer) writeFlusher {
|
|
return bufferedFlusher{
|
|
w: w,
|
|
b: &bytes.Buffer{},
|
|
}
|
|
}
|
|
|
|
func renderWithTemplate(r any, ctx context.Context, outputFormat flags.Output, w io.Writer, headerTemplate, template string) error {
|
|
// TODO: add terminal width & white/dark theme detection
|
|
switch outputFormat {
|
|
case flags.OutputJSON:
|
|
if jr, ok := r.(jsonRenderer); ok {
|
|
return jr.renderJson(ctx, newBufferedFlusher(w))
|
|
}
|
|
return errors.New("json output not supported")
|
|
case flags.OutputText:
|
|
if tr, ok := r.(templateRenderer); ok && template != "" {
|
|
return renderUsingTemplate(ctx, tr, w, headerTemplate, template)
|
|
}
|
|
if tr, ok := r.(textRenderer); ok {
|
|
return tr.renderText(ctx, w)
|
|
}
|
|
if jr, ok := r.(jsonRenderer); ok {
|
|
return jr.renderJson(ctx, newBufferedFlusher(w))
|
|
}
|
|
return errors.New("no renderer defined")
|
|
default:
|
|
return fmt.Errorf("invalid output format: %s", outputFormat)
|
|
}
|
|
}
|
|
|
|
type listingInterface interface {
|
|
HasNext(context.Context) bool
|
|
}
|
|
|
|
func Render(ctx context.Context, v any) error {
|
|
c := fromContext(ctx)
|
|
if _, ok := v.(listingInterface); ok {
|
|
panic("use RenderIterator instead")
|
|
}
|
|
return renderWithTemplate(newRenderer(v), ctx, c.outputFormat, c.out, c.headerTemplate, c.template)
|
|
}
|
|
|
|
func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error {
|
|
c := fromContext(ctx)
|
|
return renderWithTemplate(newIteratorRenderer(i), ctx, c.outputFormat, c.out, c.headerTemplate, c.template)
|
|
}
|
|
|
|
func RenderWithTemplate(ctx context.Context, v any, headerTemplate, template string) error {
|
|
c := fromContext(ctx)
|
|
if _, ok := v.(listingInterface); ok {
|
|
panic("use RenderIteratorWithTemplate instead")
|
|
}
|
|
return renderWithTemplate(newRenderer(v), ctx, c.outputFormat, c.out, headerTemplate, template)
|
|
}
|
|
|
|
func RenderIteratorWithTemplate[T any](ctx context.Context, i listing.Iterator[T], headerTemplate, template string) error {
|
|
c := fromContext(ctx)
|
|
return renderWithTemplate(newIteratorRenderer(i), ctx, c.outputFormat, c.out, headerTemplate, template)
|
|
}
|
|
|
|
func RenderIteratorJson[T any](ctx context.Context, i listing.Iterator[T]) error {
|
|
c := fromContext(ctx)
|
|
return renderWithTemplate(newIteratorRenderer(i), ctx, c.outputFormat, c.out, c.headerTemplate, c.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 ...interface{}) string {
|
|
return color.New(color.Bold).Sprintf(format, a...)
|
|
},
|
|
"italic": func(format string, a ...interface{}) string {
|
|
return color.New(color.Italic).Sprintf(format, a...)
|
|
},
|
|
"replace": strings.ReplaceAll,
|
|
"join": strings.Join,
|
|
"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
|
|
}
|
|
b, err := fancyJSON(tmp)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
},
|
|
"pretty_date": func(t time.Time) string {
|
|
return t.Format("2006-01-02T15:04:05Z")
|
|
},
|
|
"b64_encode": func(in string) (string, error) {
|
|
var out bytes.Buffer
|
|
enc := base64.NewEncoder(base64.StdEncoding, &out)
|
|
_, err := enc.Write([]byte(in))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
err = enc.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return out.String(), nil
|
|
},
|
|
"b64_decode": func(in string) (string, error) {
|
|
dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(in))
|
|
out, err := io.ReadAll(dec)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
},
|
|
}
|
|
|
|
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)
|
|
if headerTmpl != "" {
|
|
headerT, err := base.Parse(headerTmpl)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = headerT.Execute(tw, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tw.Write([]byte("\n"))
|
|
// Do not flush here. Instead, allow the first 100 resources to determine the initial spacing of the header columns.
|
|
}
|
|
t, err := base.Parse(tmpl)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = r.renderTemplate(ctx, t, tw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return tw.Flush()
|
|
}
|
|
|
|
func fancyJSON(v any) ([]byte, error) {
|
|
// create custom formatter
|
|
f := jsoncolor.NewFormatter()
|
|
|
|
// set custom colors
|
|
f.StringColor = color.New(color.FgGreen)
|
|
f.TrueColor = color.New(color.FgGreen, color.Bold)
|
|
f.FalseColor = color.New(color.FgRed)
|
|
f.NumberColor = color.New(color.FgCyan)
|
|
f.NullColor = color.New(color.FgMagenta)
|
|
f.ObjectColor = color.New(color.Reset)
|
|
f.CommaColor = color.New(color.Reset)
|
|
f.ColonColor = color.New(color.Reset)
|
|
|
|
return jsoncolor.MarshalIndentWithFormatter(v, "", " ", f)
|
|
}
|
|
|
|
const errorTemplate = `{{ "Error" | red }}: {{ .Summary }}
|
|
{{- range $index, $element := .Paths }}
|
|
{{ if eq $index 0 }}at {{else}} {{ end}}{{ $element.String | green }}
|
|
{{- end }}
|
|
{{- range $index, $element := .Locations }}
|
|
{{ if eq $index 0 }}in {{else}} {{ end}}{{ $element.String | cyan }}
|
|
{{- end }}
|
|
{{- if .Detail }}
|
|
|
|
{{ .Detail }}
|
|
{{- end }}
|
|
|
|
`
|
|
|
|
const warningTemplate = `{{ "Warning" | yellow }}: {{ .Summary }}
|
|
{{- range $index, $element := .Paths }}
|
|
{{ if eq $index 0 }}at {{else}} {{ end}}{{ $element.String | green }}
|
|
{{- end }}
|
|
{{- range $index, $element := .Locations }}
|
|
{{ if eq $index 0 }}in {{else}} {{ end}}{{ $element.String | cyan }}
|
|
{{- end }}
|
|
{{- if .Detail }}
|
|
|
|
{{ .Detail }}
|
|
{{- end }}
|
|
|
|
`
|
|
|
|
const recommendationTemplate = `{{ "Recommendation" | blue }}: {{ .Summary }}
|
|
{{- range $index, $element := .Paths }}
|
|
{{ if eq $index 0 }}at {{else}} {{ end}}{{ $element.String | green }}
|
|
{{- end }}
|
|
{{- range $index, $element := .Locations }}
|
|
{{ if eq $index 0 }}in {{else}} {{ end}}{{ $element.String | cyan }}
|
|
{{- end }}
|
|
{{- if .Detail }}
|
|
|
|
{{ .Detail }}
|
|
{{- end }}
|
|
|
|
`
|
|
|
|
func RenderDiagnosticsToErrorOut(ctx context.Context, diags diag.Diagnostics) error {
|
|
c := fromContext(ctx)
|
|
return RenderDiagnostics(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))
|
|
|
|
// Print errors and warnings.
|
|
for _, d := range diags {
|
|
var t *template.Template
|
|
switch d.Severity {
|
|
case diag.Error:
|
|
t = errorT
|
|
case diag.Warning:
|
|
t = warningT
|
|
case diag.Recommendation:
|
|
t = recommendationT
|
|
}
|
|
|
|
// Render the diagnostic with the appropriate template.
|
|
err := t.Execute(out, d)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to render template: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|