Skip to content
Open
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
2 changes: 2 additions & 0 deletions acceptance/bundle/generate/designer_job/databricks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
bundle:
name: designer_job
8 changes: 8 additions & 0 deletions acceptance/bundle/generate/designer_job/out.job.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
resources:
jobs:
out:
name: designer_job
tasks:
- task_key: test_task
notebook_task:
notebook_path: outnotebook.designer.ipynb
3 changes: 3 additions & 0 deletions acceptance/bundle/generate/designer_job/out.test.toml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions acceptance/bundle/generate/designer_job/outnotebook.designer.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 0,
"metadata": {
"application/vnd.databricks.v1+cell": {
"cellMetadata": {},
"inputWidgets": {},
"nuid": "[UUID]",
"showTitle": false,
"tableResultSettingsMap": {},
"title": ""
}
},
"outputs": [],
"source": [
"%python\n",
"\n",
"print(\"Hello, World!\")"
]
}
],
"metadata": {
"application/vnd.databricks.v1+notebook": {
"computePreferences": {
"hardware": {
"accelerator": null,
"gpuPoolId": null,
"memory": null
}
},
"dashboards": [],
"environmentMetadata": {
"base_environment": "",
"environment_version": "1"
},
"inputWidgetPreferences": null,
"language": "python",
"notebookMetadata": {
"pythonIndentUnit": 2
},
"notebookName": "test",
"widgets": {}
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}
2 changes: 2 additions & 0 deletions acceptance/bundle/generate/designer_job/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
File successfully saved to outnotebook.designer.ipynb
Job configuration successfully saved to out.job.yml
1 change: 1 addition & 0 deletions acceptance/bundle/generate/designer_job/script
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
$CLI bundle generate job --existing-job-id 1234 --config-dir . --key out --force --source-dir .
86 changes: 86 additions & 0 deletions acceptance/bundle/generate/designer_job/test.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
[[Server]]
Pattern = "GET /api/2.2/jobs/get"
Response.Body = '''
{
"job_id": 11223344,
"settings": {
"name": "designer_job",
"tasks": [
{
"task_key": "test_task",
"notebook_task": {
"notebook_path": "/Workspace/Users/tester@databricks.com/outnotebook.designer.ipynb"
}
}
]
}
}
'''

# Lakeflow Designer files are stored as DESIGNER_FILE objects and get-status
# reports no repos_export_format for them.
[[Server]]
Pattern = "GET /api/2.0/workspace/get-status"
Response.Body = '''
{
"path": "/Workspace/Users/tester@databricks.com/outnotebook.designer.ipynb",
"object_type": "DESIGNER_FILE"
}
'''

[[Server]]
Pattern = "GET /api/2.0/workspace/export"
Response.Body = '''
{
"cells": [
{
"cell_type": "code",
"execution_count": 0,
"metadata": {
"application/vnd.databricks.v1+cell": {
"cellMetadata": {},
"inputWidgets": {},
"nuid": "7027244a-b958-4dca-aca6-57a2c638f368",
"showTitle": false,
"tableResultSettingsMap": {},
"title": ""
}
},
"outputs": [],
"source": [
"%python\n",
"\n",
"print(\"Hello, World!\")"
]
}
],
"metadata": {
"application/vnd.databricks.v1+notebook": {
"computePreferences": {
"hardware": {
"accelerator": null,
"gpuPoolId": null,
"memory": null
}
},
"dashboards": [],
"environmentMetadata": {
"base_environment": "",
"environment_version": "1"
},
"inputWidgetPreferences": null,
"language": "python",
"notebookMetadata": {
"pythonIndentUnit": 2
},
"notebookName": "test",
"widgets": {}
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}
'''
29 changes: 16 additions & 13 deletions bundle/generate/downloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,27 +176,30 @@ func (n *Downloader) markNotebookForDownload(ctx context.Context, notebookPath *
}

relPath := n.relativePath(*notebookPath)
// If the path has any extension, strip it
ext := path.Ext(relPath)
if ext != "" {
relPath = strings.TrimSuffix(relPath, ext)
}

ext = notebook.GetExtensionByLanguage(&workspace.ObjectInfo{
Language: stat.Language,
ObjectType: stat.ObjectType,
})
relPath = notebook.StripExtension(relPath)

if stat.ExportFormat == workspace.ExportFormatJupyter {
ext = ".ipynb"
format := stat.ExportFormat
if fixed, ok := notebook.FixedExportFormat(stat.ObjectType); ok {
// These object types carry their full extension in the workspace path
// (preserved above) and report no export format, so we use a fixed one.
format = fixed
} else {
ext := notebook.GetExtensionByLanguage(&workspace.ObjectInfo{
Language: stat.Language,
ObjectType: stat.ObjectType,
})
if format == workspace.ExportFormatJupyter {
ext = notebook.ExtensionJupyter
}
relPath += ext
}

relPath = relPath + ext
targetPath := filepath.Join(n.sourceDir, relPath)

n.files[targetPath] = exportFile{
path: *notebookPath,
format: stat.ExportFormat,
format: format,
}

// Update the notebook path to be relative to the config dir
Expand Down
46 changes: 46 additions & 0 deletions bundle/generate/downloader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,52 @@ func notebookStatusHandler(t *testing.T) http.HandlerFunc {
}
}

// designerStatusHandler mimics get-status for a Lakeflow Designer file: object
// type DESIGNER_FILE and no export format reported.
func designerStatusHandler(t *testing.T) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/2.0/workspace/get-status" {
t.Fatalf("unexpected request path: %s", r.URL.Path)
}
resp := workspaceStatus{
ObjectType: workspace.ObjectType("DESIGNER_FILE"),
}
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(resp))
}
}

func TestDownloader_MarkTasksForDownload_DesignerNotebook(t *testing.T) {
ctx := t.Context()
w := newTestWorkspaceClient(t, designerStatusHandler(t))

dir := "base/dir"
sourceDir := filepath.Join(dir, "source")
configDir := filepath.Join(dir, "config")
downloader := NewDownloader(w, sourceDir, configDir)

tasks := []jobs.Task{
{
TaskKey: "designer_task",
NotebookTask: &jobs.NotebookTask{
NotebookPath: "/Users/user/project/My Pipeline.designer.ipynb",
},
},
}

err := downloader.MarkTasksForDownload(ctx, tasks)
require.NoError(t, err)

// The ".designer.ipynb" suffix must be preserved, not stripped to ".designer".
assert.Equal(t, "../source/My Pipeline.designer.ipynb", tasks[0].NotebookTask.NotebookPath)
require.Len(t, downloader.files, 1)
f := downloader.files[filepath.Join(sourceDir, "My Pipeline.designer.ipynb")]
assert.Equal(t, "/Users/user/project/My Pipeline.designer.ipynb", f.path)
// Designer files round-trip as Jupyter notebooks even though get-status
// reports no export format.
assert.Equal(t, workspace.ExportFormatJupyter, f.format)
}

func TestDownloader_MarkTasksForDownload_PreservesStructure(t *testing.T) {
w := newTestWorkspaceClient(t, notebookStatusHandler(t))

Expand Down
4 changes: 1 addition & 3 deletions cmd/workspace/workspace/import_dir.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"os"
"path"
"path/filepath"
"strings"

"github.com/databricks/cli/cmd/root"
"github.com/databricks/cli/libs/cmdctx"
Expand Down Expand Up @@ -96,8 +95,7 @@ func (opts importDirOptions) callback(ctx context.Context, workspaceFiler filer.
return err
}
if isNotebook {
ext := path.Ext(remoteName)
remoteName = strings.TrimSuffix(remoteName, ext)
remoteName = notebook.StripExtension(remoteName)
}

// Open the local file
Expand Down
18 changes: 18 additions & 0 deletions libs/notebook/ext.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const (
ExtensionDesigner string = ".designer.ipynb"
)

// ObjectTypeDesignerFile is the workspace object type for Lakeflow Designer
// notebooks. The SDK does not define a constant for it.
const ObjectTypeDesignerFile workspace.ObjectType = "DESIGNER_FILE"

// StripExtension returns the workspace path for a local notebook file.
// Designer files keep their full ".designer.ipynb" suffix in the workspace;
// other notebook types lose their extension on import.
Expand All @@ -30,6 +34,20 @@ func StripExtension(name string) string {
return strings.TrimSuffix(name, path.Ext(name))
}

// FixedExportFormat returns the export format an object type must be downloaded
// in when get-status reports none for it. Lakeflow Designer files (DESIGNER_FILE)
// carry their full extension in the workspace path and round-trip as Jupyter
// notebooks. The boolean is false for object types that report their own export
// format (e.g. regular notebooks).
func FixedExportFormat(objectType workspace.ObjectType) (workspace.ExportFormat, bool) {
switch objectType {
case ObjectTypeDesignerFile:
return workspace.ExportFormatJupyter, true
default:
return "", false
}
}

// Extensions lists all notebook file extensions.
var Extensions = []string{
ExtensionPython,
Expand Down
12 changes: 12 additions & 0 deletions libs/notebook/ext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package notebook
import (
"testing"

"github.com/databricks/databricks-sdk-go/service/workspace"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -30,3 +31,14 @@ func TestStripExtension(t *testing.T) {
assert.Equal(t, tc.want, StripExtension(tc.in), "input=%q", tc.in)
}
}

func TestFixedExportFormat(t *testing.T) {
// Designer files report no export format and must round-trip as Jupyter.
format, ok := FixedExportFormat(ObjectTypeDesignerFile)
assert.True(t, ok)
assert.Equal(t, workspace.ExportFormatJupyter, format)

// Regular notebooks report their own export format.
_, ok = FixedExportFormat(workspace.ObjectTypeNotebook)
assert.False(t, ok)
}
6 changes: 2 additions & 4 deletions libs/sync/snapshot_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ package sync

import (
"fmt"
"path"
"path/filepath"
"strings"
"time"

"github.com/databricks/cli/libs/fileset"
"github.com/databricks/cli/libs/notebook"
)

// SnapshotState keeps track of files on the local filesystem and their corresponding
Expand Down Expand Up @@ -57,8 +56,7 @@ func NewSnapshotState(localFiles []fileset.File) (*SnapshotState, error) {
continue
}
if isNotebook {
ext := path.Ext(remoteName)
remoteName = strings.TrimSuffix(remoteName, ext)
remoteName = notebook.StripExtension(remoteName)
}

// Add the file to snapshot state
Expand Down
20 changes: 11 additions & 9 deletions libs/sync/snapshot_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@ func TestSnapshotState(t *testing.T) {
require.NoError(t, err)

// Assert initial contents of the fileset
assert.Len(t, files, 4)
assert.Equal(t, "invalid-nb.ipynb", files[0].Relative)
assert.Equal(t, "my-nb.py", files[1].Relative)
assert.Equal(t, "my-script.py", files[2].Relative)
assert.Equal(t, "valid-nb.ipynb", files[3].Relative)
assert.Len(t, files, 5)
assert.Equal(t, "designer-nb.designer.ipynb", files[0].Relative)
assert.Equal(t, "invalid-nb.ipynb", files[1].Relative)
assert.Equal(t, "my-nb.py", files[2].Relative)
assert.Equal(t, "my-script.py", files[3].Relative)
assert.Equal(t, "valid-nb.ipynb", files[4].Relative)

// Assert snapshot state generated from the fileset. Note that the invalid notebook
// has been ignored.
// has been ignored. The Designer notebook keeps its full ".designer.ipynb" suffix
// in the remote name, while other notebooks have their extension stripped.
s, err := NewSnapshotState(files)
require.NoError(t, err)
assertKeysOfMap(t, s.LastModifiedTimes, []string{"valid-nb.ipynb", "my-nb.py", "my-script.py"})
assertKeysOfMap(t, s.LocalToRemoteNames, []string{"valid-nb.ipynb", "my-nb.py", "my-script.py"})
assertKeysOfMap(t, s.RemoteToLocalNames, []string{"valid-nb", "my-nb", "my-script.py"})
assertKeysOfMap(t, s.LastModifiedTimes, []string{"designer-nb.designer.ipynb", "valid-nb.ipynb", "my-nb.py", "my-script.py"})
assertKeysOfMap(t, s.LocalToRemoteNames, []string{"designer-nb.designer.ipynb", "valid-nb.ipynb", "my-nb.py", "my-script.py"})
assertKeysOfMap(t, s.RemoteToLocalNames, []string{"designer-nb.designer.ipynb", "valid-nb", "my-nb", "my-script.py"})
assert.NoError(t, s.validate())
}

Expand Down
Loading