diff --git a/libs/filer/workspace_files_extensions_client.go b/libs/filer/workspace_files_extensions_client.go index 44e21432c51..29adbdbddf6 100644 --- a/libs/filer/workspace_files_extensions_client.go +++ b/libs/filer/workspace_files_extensions_client.go @@ -124,6 +124,13 @@ func (w *WorkspaceFilesExtensionsClient) getNotebookStatByNameWithoutExt(ctx con ext = notebook.ExtensionJupyter } + // Extension-less notebook names cannot round-trip through [Stat] or [Read], + // which report them as not found, so return nil to have [ReadDir] omit the entry. + if ext == "" { + log.Warnf(ctx, "skipping notebook %s: no file extension is associated with notebook language %q", path.Join(w.root, name), stat.Language) + return nil, nil + } + // Modify the stat object path to include the extension. This stat object will be used // to return the fs.DirEntry object in the ReadDir method. stat.Path = stat.Path + ext @@ -205,6 +212,7 @@ func (w *WorkspaceFilesExtensionsClient) ReadDir(ctx context.Context, name strin } seenPaths := make(map[string]workspace.ObjectInfo) + result := make([]fs.DirEntry, 0, len(entries)) for i := range entries { info, err := entries[i].Info() if err != nil { @@ -218,6 +226,10 @@ func (w *WorkspaceFilesExtensionsClient) ReadDir(ctx context.Context, name strin if err != nil { return nil, err } + // A nil stat means the notebook's language has no extension mapping; omit the entry. + if stat == nil { + continue + } // Replace the entry with the new entry that includes the extension. entries[i] = wsfsDirEntry{wsfsFileInfo{ObjectInfo: stat.ObjectInfo}} } @@ -232,9 +244,10 @@ func (w *WorkspaceFilesExtensionsClient) ReadDir(ctx context.Context, name strin } } seenPaths[entries[i].Name()] = sysInfo + result = append(result, entries[i]) } - return entries, nil + return result, nil } // Note: The import API returns opaque internal errors for namespace clashes diff --git a/libs/filer/workspace_files_extensions_client_test.go b/libs/filer/workspace_files_extensions_client_test.go index b3b4861ee8e..438ac21ec47 100644 --- a/libs/filer/workspace_files_extensions_client_test.go +++ b/libs/filer/workspace_files_extensions_client_test.go @@ -201,3 +201,89 @@ func TestFilerWorkspaceFilesExtensionsErrorsOnDupName(t *testing.T) { }) } } + +func TestFilerWorkspaceFilesExtensionsReadDirWithUnmappedLanguage(t *testing.T) { + for _, tc := range []struct { + name string + language workspace.Language + notebookExportFormat workspace.ExportFormat + expectedNames []string + }{ + { + name: "source notebook with empty language is skipped", + language: "", + notebookExportFormat: workspace.ExportFormatSource, + expectedNames: []string{"bar.py"}, + }, + { + name: "source notebook with unknown language is skipped", + language: workspace.Language("FUTURELANG"), + notebookExportFormat: workspace.ExportFormatSource, + expectedNames: []string{"bar.py"}, + }, + { + name: "jupyter notebook with unknown language keeps the .ipynb extension", + language: workspace.Language("FUTURELANG"), + notebookExportFormat: workspace.ExportFormatJupyter, + expectedNames: []string{"bar.py", "foo.ipynb"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + mockedWorkspaceClient := mocks.NewMockWorkspaceClient(t) + mockedApiClient := mockApiClient{} + + workspaceApi := mockedWorkspaceClient.GetMockWorkspaceAPI() + workspaceApi.EXPECT().ListAll(mock.Anything, workspace.ListWorkspaceRequest{ + Path: "/dir", + }).Return([]workspace.ObjectInfo{ + { + Path: "/dir/bar.py", + ObjectType: workspace.ObjectTypeFile, + }, + { + Path: "/dir/foo", + Language: tc.language, + ObjectType: workspace.ObjectTypeNotebook, + }, + }, nil) + + // Mock the get-status call used to figure out the notebook's file extension. + statNotebook := wsfsFileInfo{ + ObjectInfo: workspace.ObjectInfo{ + Path: "/dir/foo", + Language: tc.language, + ObjectType: workspace.ObjectTypeNotebook, + }, + ReposExportFormat: tc.notebookExportFormat, + } + + mockedApiClient.On("Do", mock.Anything, http.MethodGet, "/api/2.0/workspace/get-status", map[string]string(nil), map[string]string{ + "path": "/dir/foo", + "return_export_info": "true", + }, mock.AnythingOfType("*filer.wsfsFileInfo"), []func(*http.Request) error(nil)).Return(nil, statNotebook) + + workspaceFilesClient := WorkspaceFilesClient{ + workspaceClient: mockedWorkspaceClient.WorkspaceClient, + apiClient: &mockedApiClient, + root: NewWorkspaceRootPath("/dir"), + } + + workspaceFilesExtensionsClient := WorkspaceFilesExtensionsClient{ + workspaceClient: mockedWorkspaceClient.WorkspaceClient, + wsfs: &workspaceFilesClient, + } + + entries, err := workspaceFilesExtensionsClient.ReadDir(t.Context(), "/") + assert.NoError(t, err) + + names := make([]string, len(entries)) + for i, entry := range entries { + names[i] = entry.Name() + } + assert.Equal(t, tc.expectedNames, names) + + workspaceApi.AssertNumberOfCalls(t, "ListAll", 1) + mockedApiClient.AssertNumberOfCalls(t, "Do", 1) + }) + } +}