Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion acceptance/apps/deploy/no-bundle-no-args/output.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
Error: accepts 1 arg(s), received 0
Error: missing required argument: APP_NAME

Usage: databricks apps deploy APP_NAME

APP_NAME is the name of the Databricks app to operate on.
Alternatively, run this command from a project directory containing
databricks.yml to auto-detect the app name.

Exit code: 1
63 changes: 62 additions & 1 deletion cmd/apps/bundle_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -27,12 +29,71 @@ func makeArgsOptionalWithBundle(cmd *cobra.Command, usage string) {
return fmt.Errorf("accepts at most 1 arg(s), received %d", len(args))
}
if !hasBundleConfig() && len(args) != 1 {
return fmt.Errorf("accepts 1 arg(s), received %d", len(args))
return missingAppNameError(cmd)
}
return nil
}
}

// missingAppNameError returns an error message that explains what the positional
// argument should be, and attempts to infer a suggestion from the local environment.
// The full subcommand path (e.g. "databricks apps start") is rendered from cmd so
// the usage line and "Did you mean?" hint match the verb the user actually ran.
func missingAppNameError(cmd *cobra.Command) error {
hint := inferAppNameHint()
commandPath := "databricks apps <command>"
argName := "APP_NAME"
if cmd != nil {
if p := cmd.CommandPath(); p != "" {
commandPath = p
}
if name := positionalArgName(cmd.Use); name != "" {
argName = name
}
}
msg := fmt.Sprintf(`missing required argument: %s

Usage: %s %s

%s is the name of the Databricks app to operate on.
Alternatively, run this command from a project directory containing
databricks.yml to auto-detect the app name.`, argName, commandPath, argName, argName)

if hint != "" {
msg += fmt.Sprintf("\n\nDid you mean?\n %s %s", commandPath, hint)
}

return errors.New(msg)
}

func positionalArgName(use string) string {
start := strings.Index(use, "[")
end := strings.Index(use, "]")
if start < 0 || end <= start {
return ""
}
return use[start+1 : end]
}

// inferAppNameHint tries to suggest an app name from the local environment.
// Only returns a hint if the current directory looks like a Databricks app
// (contains app.yml or app.yaml), using the directory name as the suggestion.
func inferAppNameHint() string {
wd, err := os.Getwd()
if err != nil {
return ""
}

for _, filename := range []string{"app.yml", "app.yaml"} {
info, err := os.Stat(filepath.Join(wd, filename))
if err == nil && info.Mode().IsRegular() {
return filepath.Base(wd)
}
}

return ""
}

// getAppNameFromArgs returns the app name from args or detects it from the bundle.
// Returns (appName, fromBundle, error).
func getAppNameFromArgs(cmd *cobra.Command, args []string) (string, bool, error) {
Expand Down
119 changes: 119 additions & 0 deletions cmd/apps/bundle_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package apps

import (
"errors"
"os"
"path/filepath"
"testing"

"github.com/databricks/databricks-sdk-go/service/apps"
Expand Down Expand Up @@ -105,6 +107,112 @@ func TestFormatAppStatusMessage(t *testing.T) {
})
}

func TestInferAppNameHint(t *testing.T) {
t.Run("returns empty when no app config exists", func(t *testing.T) {
t.Chdir(t.TempDir())

assert.Equal(t, "", inferAppNameHint())
})

t.Run("returns dir name when app.yml exists", func(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
err := os.WriteFile(filepath.Join(dir, "app.yml"), []byte("command: [\"python\"]"), 0o644)
assert.NoError(t, err)

assert.Equal(t, filepath.Base(dir), inferAppNameHint())
})

t.Run("returns dir name when app.yaml exists", func(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("command: [\"python\"]"), 0o644)
assert.NoError(t, err)

assert.Equal(t, filepath.Base(dir), inferAppNameHint())
})

t.Run("returns empty when cwd has been deleted", func(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
os.Remove(dir)

assert.Equal(t, "", inferAppNameHint())
})
}

func TestMissingAppNameError(t *testing.T) {
t.Run("includes APP_NAME and usage info", func(t *testing.T) {
t.Chdir(t.TempDir())

err := missingAppNameError(nil)
assert.Contains(t, err.Error(), "APP_NAME")
assert.Contains(t, err.Error(), "databricks.yml")
assert.NotContains(t, err.Error(), "Did you mean")
})

t.Run("includes hint when app.yml exists", func(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
writeErr := os.WriteFile(filepath.Join(dir, "app.yml"), []byte("command: [\"python\"]"), 0o644)
assert.NoError(t, writeErr)

err := missingAppNameError(nil)
assert.Contains(t, err.Error(), "Did you mean")
assert.Contains(t, err.Error(), filepath.Base(dir))
})

t.Run("gracefully handles deleted cwd", func(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
os.Remove(dir)

err := missingAppNameError(nil)
assert.Contains(t, err.Error(), "APP_NAME")
assert.NotContains(t, err.Error(), "Did you mean")
})

t.Run("renders usage and hint from cmd path per verb", func(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
writeErr := os.WriteFile(filepath.Join(dir, "app.yml"), []byte("command: [\"python\"]"), 0o644)
assert.NoError(t, writeErr)

for _, tc := range []struct {
verb string
use string
arg string
}{
{"deploy", "deploy [APP_NAME]", "APP_NAME"},
{"start", "start [NAME]", "NAME"},
{"stop", "stop [NAME]", "NAME"},
{"delete", "delete [NAME]", "NAME"},
} {
t.Run(tc.verb, func(t *testing.T) {
root := &cobra.Command{Use: "databricks"}
apps := &cobra.Command{Use: "apps"}
sub := &cobra.Command{Use: tc.use}
root.AddCommand(apps)
apps.AddCommand(sub)

err := missingAppNameError(sub)
assert.Contains(t, err.Error(), "missing required argument: "+tc.arg)
assert.Contains(t, err.Error(), "Usage: databricks apps "+tc.verb+" "+tc.arg)
assert.Contains(t, err.Error(), "databricks apps "+tc.verb+" "+filepath.Base(dir))
})
}
})

t.Run("ignores non-regular app.yml entries", func(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
assert.NoError(t, os.Mkdir(filepath.Join(dir, "app.yml"), 0o755))

err := missingAppNameError(nil)
assert.NotContains(t, err.Error(), "Did you mean")
})
}

func TestMakeArgsOptionalWithBundle(t *testing.T) {
t.Run("updates command usage", func(t *testing.T) {
cmd := &cobra.Command{}
Expand All @@ -117,6 +225,17 @@ func TestMakeArgsOptionalWithBundle(t *testing.T) {
makeArgsOptionalWithBundle(cmd, "test [NAME]")
assert.NotNil(t, cmd.Args)
})

t.Run("returns missing app name error when no bundle config exists", func(t *testing.T) {
t.Chdir(t.TempDir())

cmd := &cobra.Command{}
makeArgsOptionalWithBundle(cmd, "test [NAME]")

err := cmd.Args(cmd, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing required argument: NAME")
})
}

func TestGetAppNameFromArgs(t *testing.T) {
Expand Down
11 changes: 1 addition & 10 deletions cmd/apps/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ func newLogsCommand() *cobra.Command {
)

cmd := &cobra.Command{
Use: "logs [NAME]",
Short: "Show Databricks app logs",
Long: `Show Databricks app logs.

Expand Down Expand Up @@ -78,15 +77,6 @@ Examples:

# Mirror streamed logs to a local file while following for up to 5 minutes
databricks apps logs my-app --follow --timeout 5m --output-file /tmp/my-app.log`,
Args: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return fmt.Errorf("accepts at most 1 arg(s), received %d", len(args))
}
if !hasBundleConfig() && len(args) != 1 {
return fmt.Errorf("accepts 1 arg(s), received %d", len(args))
}
return nil
},
PreRunE: root.MustWorkspaceClient,
RunE: func(cmd *cobra.Command, args []string) error {
appName, fromBundle, err := getAppNameFromArgs(cmd, args)
Expand Down Expand Up @@ -207,6 +197,7 @@ Examples:
})
},
}
makeArgsOptionalWithBundle(cmd, "logs [NAME]")

streamGroup := cmdgroup.NewFlagGroup("Streaming")
streamGroup.FlagSet().IntVar(&tailLines, "tail-lines", defaultTailLines, "Number of recent log lines to show before streaming. Set to 0 to show everything.")
Expand Down
11 changes: 11 additions & 0 deletions cmd/apps/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ func TestBuildLogsURLRejectsUnknownScheme(t *testing.T) {
require.Error(t, err)
}

func TestLogsMissingNameError(t *testing.T) {
t.Chdir(t.TempDir())

cmd := newLogsCommand()
err := cmd.Args(cmd, nil)

require.Error(t, err)
assert.Contains(t, err.Error(), "missing required argument: NAME")
assert.Contains(t, err.Error(), "Usage: logs NAME")
}

func TestNormalizeOrigin(t *testing.T) {
assert.Equal(t, "https://example.com", normalizeOrigin("https://example.com/foo"))
assert.Equal(t, "http://example.com", normalizeOrigin("ws://example.com/foo"))
Expand Down
Loading