Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add command line autocomplete to the fs commands #1622

Merged
merged 43 commits into from
Aug 9, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
0095d15
Add autocomplete to fs commands
andersrexdb Jul 22, 2024
e8e93fd
Add tests
andersrexdb Jul 24, 2024
300ab07
Lint
andersrexdb Jul 24, 2024
ba64fd3
Fix TestGlobFileset
andersrexdb Jul 24, 2024
5d6a348
Comments
andersrexdb Jul 24, 2024
e3f6a73
Add onlyDirs
andersrexdb Jul 24, 2024
787d563
Add HasWorkspaceClient
andersrexdb Jul 24, 2024
a43b4af
Add file+dir test
andersrexdb Jul 24, 2024
13eb013
PR comments
andersrexdb Jul 25, 2024
3856332
Programmatically add dbfs:/Volumes
andersrexdb Jul 25, 2024
c73c09b
PR feedback
andersrexdb Jul 27, 2024
363bd1d
Client
andersrexdb Jul 29, 2024
0716f31
Comment
andersrexdb Jul 29, 2024
0341e8b
Cleanup
andersrexdb Jul 29, 2024
a42f121
Support .\ local paths with Windows
andersrexdb Aug 2, 2024
6d10776
Handle local Windows paths
andersrexdb Aug 2, 2024
7c010cc
Support both separators on Windows
andersrexdb Aug 2, 2024
e0d2062
Fix test
andersrexdb Aug 2, 2024
d990fe2
Fix test 2
andersrexdb Aug 2, 2024
1868791
PR feedback
andersrexdb Aug 5, 2024
c4a406c
Remove goroutine
andersrexdb Aug 5, 2024
a909d1f
Doc comments
andersrexdb Aug 5, 2024
6091e0d
Use path and filepath for joining paths
andersrexdb Aug 5, 2024
1591896
Use struct for Validate
andersrexdb Aug 5, 2024
84229be
Lowercase validArgs
andersrexdb Aug 6, 2024
058183e
Add integration test
andersrexdb Aug 6, 2024
5df74cf
Fix error
andersrexdb Aug 6, 2024
0803381
DRY up test
andersrexdb Aug 6, 2024
fb90be9
Use fakeFiler in tests
andersrexdb Aug 6, 2024
d802ba7
Remove PrepFakeFiler
andersrexdb Aug 6, 2024
5261a2c
Remove call to current user
andersrexdb Aug 6, 2024
3514151
Add integration test
andersrexdb Aug 6, 2024
2057aad
Put back GetEnvOrSkipTest
andersrexdb Aug 6, 2024
25e4443
PR feedback
andersrexdb Aug 7, 2024
1b168ae
Cleanup
andersrexdb Aug 7, 2024
c2ae3f0
Simplify path.Dir logic
andersrexdb Aug 7, 2024
e5b32fc
Comments
andersrexdb Aug 7, 2024
81775a4
Cleanup
andersrexdb Aug 7, 2024
57ba288
Add windows test
andersrexdb Aug 7, 2024
0eac4e7
PR comments
andersrexdb Aug 9, 2024
6c85d51
Fix TestGlobFileset
andersrexdb Aug 9, 2024
324e4ab
Try again
andersrexdb Aug 9, 2024
354f4b9
Dont use ../filer in test
andersrexdb Aug 9, 2024
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
2 changes: 1 addition & 1 deletion cmd/fs/cat.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func newCatCommand() *cobra.Command {
return cmdio.Render(ctx, r)
}

cmd.ValidArgsFunction = getValidArgsFunction(1, false, filerForPath)
cmd.ValidArgsFunction = getValidArgsFunction(1, false, filerForPath, root.MustWorkspaceClient)

return cmd
}
2 changes: 1 addition & 1 deletion cmd/fs/cp.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ func newCpCommand() *cobra.Command {
}

// The copy command has two paths that can be completed (SOURCE_PATH & TARGET_PATH)
cmd.ValidArgsFunction = getValidArgsFunction(2, false, filerForPath)
cmd.ValidArgsFunction = getValidArgsFunction(2, false, filerForPath, root.MustWorkspaceClient)
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved

return cmd
}
74 changes: 40 additions & 34 deletions cmd/fs/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/completer"
"github.com/databricks/cli/libs/filer"
"github.com/fatih/color"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -50,41 +51,34 @@ func filerForPath(ctx context.Context, fullPath string) (filer.Filer, string, er

const dbfsPrefix string = "dbfs:/"
const volumesPefix string = "dbfs:/Volumes"
const localPefix string = "./"
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved

func isDbfsPath(path string) bool {
return strings.HasPrefix(path, dbfsPrefix)
}

func getValidArgsFunction(pathArgCount int, onlyDirs bool, filerForPathFunc func(ctx context.Context, fullPath string) (filer.Filer, string, error)) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
func getValidArgsFunction(
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved
pathArgCount int,
onlyDirs bool,
filerForPathFunc func(ctx context.Context, fullPath string) (filer.Filer, string, error),
mustWorkspaceClientFunc func(cmd *cobra.Command, args []string) error,
) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved
cmd.SetContext(root.SkipPrompt(cmd.Context()))

err := mustWorkspaceClient(cmd, args) // TODO: Fix this
err := mustWorkspaceClientFunc(cmd, args)

if err != nil {
return nil, cobra.ShellCompDirectiveError
}

prefixes := []string{
dbfsPrefix,
"/",
}

selectedPrefix := ""
for _, p := range prefixes {
if strings.HasPrefix(toComplete, p) {
selectedPrefix = p
}
}

if selectedPrefix == "" {
return prefixes, cobra.ShellCompDirectiveNoSpace
}

filer, path, err := filerForPathFunc(cmd.Context(), toComplete)
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

isDbfsPath := isDbfsPath(toComplete)

wsc := root.WorkspaceClient(cmd.Context())
_, err = wsc.CurrentUser.Me(cmd.Context())
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
Expand All @@ -99,30 +93,42 @@ func getValidArgsFunction(pathArgCount int, onlyDirs bool, filerForPathFunc func

completions, directive := completer.CompleteRemotePath(path)

// The completions will start with a "/", so we'll prefix
// them with the selectedPrefix without a trailing "/"
prefix := selectedPrefix[:len(selectedPrefix)-1]

// Add the prefix to the completions
for i, completion := range completions {
completions[i] = fmt.Sprintf("%s%s", prefix, completion)
if isDbfsPath {
// The completions will start with a "/", so we'll prefix them with the
// selectedPrefix without a trailing "/"
prefix := dbfsPrefix[:len(dbfsPrefix)-1]
completions[i] = fmt.Sprintf("%s%s", prefix, completion)
} else if shouldDropLocalPrefix(toComplete, completion) {

completions[i] = completion[len(localPefix):]
} else {
completions[i] = completion
}
}

// If the path is a dbfs path, we try to add the dbfs:/Volumes prefix suggestion
if isDbfsPath && strings.HasPrefix(volumesPefix, toComplete) {
completions = append([]string{highlight(volumesPefix)}, completions...)

}

// If the path is a dbfs path, we should also add the Volumes prefix
if strings.HasPrefix(volumesPefix, toComplete) {
completions = append(completions, volumesPefix)
// If the path is local, we try to prepend the dbfs:/ prefix suggestion
if !isDbfsPath && strings.HasPrefix(dbfsPrefix, toComplete) {
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved
completions = append([]string{highlight(dbfsPrefix)}, completions...)
}

return completions, directive
}
}

// Wrapper for [root.MustWorkspaceClient] that disables loading authentication configuration from a bundle.
func mustWorkspaceClient(cmd *cobra.Command, args []string) error {
if root.HasWorkspaceClient(cmd.Context()) {
return nil
}
// Drop the local prefix from completions if the path to complete doesn't
// start with it. We do this because the local filer returns paths with the
// local prefix.
func shouldDropLocalPrefix(toComplete string, completion string) bool {
return !strings.HasPrefix(toComplete, localPefix) && strings.HasPrefix(completion, localPefix)
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved
}

cmd.SetContext(root.SkipLoadBundle(cmd.Context()))
return root.MustWorkspaceClient(cmd, args)
func highlight(c string) string {
return color.New(color.FgCyan, color.Bold).Sprintf(c)
}
62 changes: 44 additions & 18 deletions cmd/fs/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ func mockCurrentUserApi(m *mocks.MockWorkspaceClient, u *iam.User, e error) {
currentUserApi.EXPECT().Me(mock.AnythingOfType("*context.valueCtx")).Return(u, e)
}

func mockMustWorkspaceClientFunc(cmd *cobra.Command, args []string) error {
return nil
}

func setupValidArgsFunctionTest(t *testing.T) (*mocks.MockWorkspaceClient, *cobra.Command) {
m := mocks.NewMockWorkspaceClient(t)
ctx := context.Background()
Expand All @@ -90,16 +94,16 @@ func TestGetValidArgsFunctionCompletion(t *testing.T) {

mockCurrentUserApi(m, nil, nil)

mockFilerForPath := testutil.GetMockFilerForPath(t, []fs.DirEntry{
mockFilerForPath := testutil.GetMockFilerForPath(t, "/", []fs.DirEntry{
testutil.NewFakeDirEntry("dir", true),
testutil.NewFakeDirEntry("file", false),
})

validArgsFunction := getValidArgsFunction(1, false, mockFilerForPath)
validArgsFunction := getValidArgsFunction(1, false, mockFilerForPath, mockMustWorkspaceClientFunc)
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved
completions, directive := validArgsFunction(cmd, []string{}, "dbfs:/")

// dbfs:/Volumes is programmatically added to the completions
assert.Equal(t, []string{"dbfs:/dir", "dbfs:/file", "dbfs:/Volumes"}, completions)
assert.Equal(t, []string{"dbfs:/Volumes", "dbfs:/dir", "dbfs:/file"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
}

Expand All @@ -108,28 +112,33 @@ func TestGetValidArgsFunctionCompletionOnlyDirs(t *testing.T) {

mockCurrentUserApi(m, nil, nil)

mockFilerForPath := testutil.GetMockFilerForPath(t, []fs.DirEntry{
mockFilerForPath := testutil.GetMockFilerForPath(t, "/", []fs.DirEntry{
testutil.NewFakeDirEntry("dir", true),
testutil.NewFakeDirEntry("file", false),
})

validArgsFunction := getValidArgsFunction(1, true, mockFilerForPath)
validArgsFunction := getValidArgsFunction(1, true, mockFilerForPath, mockMustWorkspaceClientFunc)
completions, directive := validArgsFunction(cmd, []string{}, "dbfs:/")

assert.Equal(t, []string{"dbfs:/dir", "dbfs:/Volumes"}, completions)
assert.Equal(t, []string{"dbfs:/Volumes", "dbfs:/dir"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
}

func TestGetValidArgsFunctionNoPath(t *testing.T) {
_, cmd := setupValidArgsFunctionTest(t)
m, cmd := setupValidArgsFunctionTest(t)

mockFilerForPath := testutil.GetMockFilerForPath(t, nil)
mockCurrentUserApi(m, nil, nil)

validArgsFunction := getValidArgsFunction(1, true, mockFilerForPath)
completions, directive := validArgsFunction(cmd, []string{}, "")
mockFilerForPath := testutil.GetMockFilerForPath(t, "", []fs.DirEntry{
testutil.NewFakeDirEntry("dFile1", false),
testutil.NewFakeDirEntry("dFile2", false),
})

validArgsFunction := getValidArgsFunction(1, false, mockFilerForPath, mockMustWorkspaceClientFunc)
completions, directive := validArgsFunction(cmd, []string{}, "d")

// Suggest both dbfs and local paths at beginning of completion
assert.Equal(t, []string{"dbfs:/", "/"}, completions)
assert.Equal(t, []string{"dbfs:/", "dFile1", "dFile2"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
}

Expand All @@ -138,25 +147,42 @@ func TestGetValidArgsFunctionLocalPath(t *testing.T) {

mockCurrentUserApi(m, nil, nil)

mockFilerForPath := testutil.GetMockFilerForPath(t, []fs.DirEntry{
mockFilerForPath := testutil.GetMockFilerForPath(t, "", []fs.DirEntry{
testutil.NewFakeDirEntry("dir", true),
testutil.NewFakeDirEntry("file", false),
})

validArgsFunction := getValidArgsFunction(1, false, mockFilerForPath, mockMustWorkspaceClientFunc)
completions, directive := validArgsFunction(cmd, []string{}, "")

assert.Equal(t, []string{"dbfs:/", "dir", "file"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
}

func TestGetValidArgsFunctionAbsoluteLocalPath(t *testing.T) {
m, cmd := setupValidArgsFunctionTest(t)

mockCurrentUserApi(m, nil, nil)

mockFilerForPath := testutil.GetMockFilerForPath(t, "", []fs.DirEntry{
testutil.NewFakeDirEntry("dir", true),
testutil.NewFakeDirEntry("file", false),
})

validArgsFunction := getValidArgsFunction(1, false, mockFilerForPath)
validArgsFunction := getValidArgsFunction(1, false, mockFilerForPath, mockMustWorkspaceClientFunc)
completions, directive := validArgsFunction(cmd, []string{}, "/")

assert.Equal(t, []string{"/dir", "/file"}, completions)
assert.Equal(t, []string{"dir", "file"}, completions)
assert.Equal(t, cobra.ShellCompDirectiveNoSpace, directive)
}

func TestGetValidArgsFunctionNoCurrentUser(t *testing.T) {
m, cmd := setupValidArgsFunctionTest(t)

mockCurrentUserApi(m, nil, errors.New("Current User Error"))
mockFilerForPath := testutil.GetMockFilerForPath(t, nil)
mockFilerForPath := testutil.GetMockFilerForPath(t, "/", nil)

validArgsFunction := getValidArgsFunction(1, true, mockFilerForPath)
validArgsFunction := getValidArgsFunction(1, true, mockFilerForPath, mockMustWorkspaceClientFunc)
completions, directive := validArgsFunction(cmd, []string{}, "dbfs:/")

assert.Nil(t, completions)
Expand All @@ -167,10 +193,10 @@ func TestGetValidArgsFunctionNotCompletedArgument(t *testing.T) {
m, cmd := setupValidArgsFunctionTest(t)

mockCurrentUserApi(m, nil, nil)
mockFilerForPath := testutil.GetMockFilerForPath(t, nil)
mockFilerForPath := testutil.GetMockFilerForPath(t, "/", nil)

// 0 here means we don't complete any arguments
validArgsFunction := getValidArgsFunction(0, true, mockFilerForPath)
validArgsFunction := getValidArgsFunction(0, true, mockFilerForPath, mockMustWorkspaceClientFunc)
completions, directive := validArgsFunction(cmd, []string{}, "dbfs:/")

assert.Nil(t, completions)
Expand Down
2 changes: 1 addition & 1 deletion cmd/fs/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func newLsCommand() *cobra.Command {
`))
}

cmd.ValidArgsFunction = getValidArgsFunction(1, true, filerForPath)
cmd.ValidArgsFunction = getValidArgsFunction(1, true, filerForPath, root.MustWorkspaceClient)

return cmd
}
2 changes: 1 addition & 1 deletion cmd/fs/mkdir.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func newMkdirCommand() *cobra.Command {
return f.Mkdir(ctx, path)
}

cmd.ValidArgsFunction = getValidArgsFunction(1, true, filerForPath)
cmd.ValidArgsFunction = getValidArgsFunction(1, true, filerForPath, root.MustWorkspaceClient)

return cmd
}
2 changes: 1 addition & 1 deletion cmd/fs/rm.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func newRmCommand() *cobra.Command {
return f.Delete(ctx, path)
}

cmd.ValidArgsFunction = getValidArgsFunction(1, false, filerForPath)
cmd.ValidArgsFunction = getValidArgsFunction(1, false, filerForPath, root.MustWorkspaceClient)

return cmd
}
6 changes: 3 additions & 3 deletions internal/testutil/filer.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@ func NewFakeDirEntry(name string, dir bool) fs.DirEntry {
return fakeDirEntry{fakeFileInfo{name: name, dir: dir}}
}

func GetMockFilerForPath(t *testing.T, entries []fs.DirEntry) func(ctx context.Context, fullPath string) (filer.Filer, string, error) {
func GetMockFilerForPath(t *testing.T, path string, entries []fs.DirEntry) func(ctx context.Context, fullPath string) (filer.Filer, string, error) {
andersrexdb marked this conversation as resolved.
Show resolved Hide resolved
mockFiler := mockfiler.NewMockFiler(t)
if entries != nil {
mockFiler.EXPECT().ReadDir(mock.AnythingOfType("*context.valueCtx"), mock.Anything).Return(entries, nil)
mockFiler.EXPECT().ReadDir(mock.AnythingOfType("*context.valueCtx"), path).Return(entries, nil)
}
mockFilerForPath := func(ctx context.Context, fullPath string) (filer.Filer, string, error) {
return mockFiler, "/", nil
return mockFiler, path, nil
}
return mockFilerForPath
}
11 changes: 3 additions & 8 deletions libs/completer/completer.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import (
"context"
"fmt"
"path"
"sort"
"strings"

"github.com/databricks/cli/libs/filer"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -57,8 +57,8 @@ func fetchCompletions(

// Ensure the path and name have a "/" separating them. We don't use path
// utilities to concatenate the path and name because we want to preserve
// the formatting of the path the user has typed (e.g. //a/b///c)
if remotePath[len(remotePath)-1] != '/' {
// the formatting of whatever path the user has typed (e.g. //a/b///c)
if len(remotePath) > 0 && !strings.HasSuffix(remotePath, "/") {
separator = "/"
}

Expand All @@ -67,11 +67,6 @@ func fetchCompletions(
}
}

// Sort completions alphabetically
sort.Slice(completions, func(i, j int) bool {
return completions[i] < completions[j]
})

ch <- completions
}()

Expand Down
8 changes: 4 additions & 4 deletions libs/completer/completer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func setup(t *testing.T) context.Context {
func TestFilerCompleterReturnsNestedDirs(t *testing.T) {
ctx := setup(t)

mockFilerForPath := testutil.GetMockFilerForPath(t, []fs.DirEntry{
mockFilerForPath := testutil.GetMockFilerForPath(t, "/", []fs.DirEntry{
testutil.NewFakeDirEntry("dir", true),
})
mockFiler, _, _ := mockFilerForPath(ctx, "dbfs:/")
Expand Down Expand Up @@ -76,7 +76,7 @@ func TestFilerCompleterReadDirError(t *testing.T) {
func TestFilerCompleterReturnsFileAndDir(t *testing.T) {
ctx := setup(t)

mockFilerForPath := testutil.GetMockFilerForPath(t, []fs.DirEntry{
mockFilerForPath := testutil.GetMockFilerForPath(t, "/", []fs.DirEntry{
testutil.NewFakeDirEntry("dir", true),
testutil.NewFakeDirEntry("file", false),
})
Expand All @@ -94,7 +94,7 @@ func TestFilerCompleterReturnsFileAndDir(t *testing.T) {
func TestFilerCompleterRetainsFormatting(t *testing.T) {
ctx := setup(t)

mockFilerForPath := testutil.GetMockFilerForPath(t, []fs.DirEntry{
mockFilerForPath := testutil.GetMockFilerForPath(t, "//dir//", []fs.DirEntry{
testutil.NewFakeDirEntry("nested_dir", true),
})
mockFiler, _, _ := mockFilerForPath(ctx, "dbfs://dir")
Expand All @@ -110,7 +110,7 @@ func TestFilerCompleterRetainsFormatting(t *testing.T) {
func TestFilerCompleterAddsSeparator(t *testing.T) {
ctx := setup(t)

mockFilerForPath := testutil.GetMockFilerForPath(t, []fs.DirEntry{
mockFilerForPath := testutil.GetMockFilerForPath(t, "/dir", []fs.DirEntry{
testutil.NewFakeDirEntry("nested_dir", true),
})
mockFiler, _, _ := mockFilerForPath(ctx, "dbfs:/dir")
Expand Down
Loading