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
1 change: 1 addition & 0 deletions NEXT_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@

### Bundles
* Retry transient HTTP 504 Gateway Timeout errors in direct deployment engine ([#5349](https://github.com/databricks/cli/pull/5349)).
* Preserve `.designer.ipynb` suffix when translating notebook task paths so Lakeflow Designer files referenced from a `notebook_task` resolve correctly in the workspace ([#5370](https://github.com/databricks/cli/pull/5370)).

### Dependency updates
14 changes: 14 additions & 0 deletions acceptance/bundle/paths/designer_notebook/databricks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
bundle:
name: designer_paths

resources:
jobs:
designer_job:
name: designer_job
tasks:
- task_key: designer_task
notebook_task:
notebook_path: ./src/test.designer.ipynb
- task_key: regular_task
notebook_task:
notebook_path: ./src/regular.ipynb
10 changes: 10 additions & 0 deletions acceptance/bundle/paths/designer_notebook/output.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[
{
"task_key": "designer_task",
"notebook_path": "/Workspace/Users/[USERNAME]/.bundle/designer_paths/default/files/src/test.designer.ipynb"
},
{
"task_key": "regular_task",
"notebook_path": "/Workspace/Users/[USERNAME]/.bundle/designer_paths/default/files/src/regular"
}
]
1 change: 1 addition & 0 deletions acceptance/bundle/paths/designer_notebook/script
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
$CLI bundle validate -o json | jq '.resources.jobs.designer_job.tasks | map({task_key, notebook_path: .notebook_task.notebook_path})'
10 changes: 10 additions & 0 deletions acceptance/bundle/paths/designer_notebook/src/regular.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"cells": [],
"metadata": {
"application/vnd.databricks.v1+notebook": {
"language": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}
10 changes: 10 additions & 0 deletions acceptance/bundle/paths/designer_notebook/src/test.designer.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"cells": [],
"metadata": {
"application/vnd.databricks.v1+notebook": {
"language": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}
9 changes: 4 additions & 5 deletions bundle/config/mutator/translate_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,7 @@ func (t *translateContext) rewritePath(

func (t *translateContext) translateNotebookPath(ctx context.Context, literal, localFullPath, localRelPath string) (string, error) {
if t.skipLocalFileValidation {
localRelPathNoExt := strings.TrimSuffix(localRelPath, path.Ext(localRelPath))
return path.Join(t.remoteRoot, localRelPathNoExt), nil
return path.Join(t.remoteRoot, notebook.StripExtension(localRelPath)), nil
}

nb, _, err := notebook.DetectWithFS(t.b.SyncRoot, localRelPath)
Expand Down Expand Up @@ -229,9 +228,9 @@ to contain one of the following file extensions: [%s]`, literal, strings.Join(no
return "", ErrIsNotNotebook{localFullPath}
}

// Upon import, notebooks are stripped of their extension.
localRelPathNoExt := strings.TrimSuffix(localRelPath, path.Ext(localRelPath))
return path.Join(t.remoteRoot, localRelPathNoExt), nil
// Upon import, notebooks are stripped of their extension. Designer files
// keep their full ".designer.ipynb" suffix.
return path.Join(t.remoteRoot, notebook.StripExtension(localRelPath)), nil
}

func (t *translateContext) translateFilePath(ctx context.Context, literal, localFullPath, localRelPath string) (string, error) {
Expand Down
116 changes: 116 additions & 0 deletions bundle/config/mutator/translate_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ func touchNotebookFile(t *testing.T, path string) {
f.Close()
}

// touchDesignerFile writes a minimal valid Lakeflow Designer notebook (Jupyter
// JSON) so that notebook.DetectWithFS recognizes it as a notebook.
func touchDesignerFile(t *testing.T, path string) {
err := os.MkdirAll(filepath.Dir(path), 0o700)
require.NoError(t, err)
contents := `{"cells":[],"metadata":{"application/vnd.databricks.v1+notebook":{"language":"python"}},"nbformat":4,"nbformat_minor":0}`
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
}

func touchEmptyFile(t *testing.T, path string) {
err := os.MkdirAll(filepath.Dir(path), 0o700)
require.NoError(t, err)
Expand Down Expand Up @@ -1124,3 +1133,110 @@ func TestTranslatePathsWithSkipLocalFileValidationDirectory(t *testing.T) {
// Directory path should be translated even though directory doesn't exist.
assert.Equal(t, "/bundle/pipeline_root", b.Config.Resources.Pipelines["pipeline"].RootPath)
}

// TestTranslatePathsDesignerNotebook verifies that Lakeflow Designer notebooks
// (`*.designer.ipynb`) referenced by a notebook_task preserve their full
// suffix in the deployed notebook_path, since the workspace keeps that suffix
// on import (unlike regular `.ipynb`, which is stripped).
func TestTranslatePathsDesignerNotebook(t *testing.T) {
dir := t.TempDir()
touchDesignerFile(t, filepath.Join(dir, "src", "designer.designer.ipynb"))
touchNotebookFile(t, filepath.Join(dir, "src", "regular.py"))

b := &bundle.Bundle{
SyncRootPath: dir,
BundleRootPath: dir,
SyncRoot: vfs.MustNew(dir),
Config: config.Root{
Workspace: config.Workspace{
FilePath: "/bundle",
},
Resources: config.Resources{
Jobs: map[string]*resources.Job{
"job": {
JobSettings: jobs.JobSettings{
Tasks: []jobs.Task{
{
NotebookTask: &jobs.NotebookTask{
NotebookPath: "./src/designer.designer.ipynb",
},
},
{
NotebookTask: &jobs.NotebookTask{
NotebookPath: "./src/regular.py",
},
},
},
},
},
},
},
},
}

bundletest.SetLocation(b, ".", []dyn.Location{{File: filepath.Join(dir, "databricks.yml")}})

diags := bundle.ApplySeq(t.Context(), b, mutator.NormalizePaths(), mutator.TranslatePaths())
require.NoError(t, diags.Error())

// Designer notebook keeps its full ".designer.ipynb" suffix.
assert.Equal(t,
"/bundle/src/designer.designer.ipynb",
b.Config.Resources.Jobs["job"].Tasks[0].NotebookTask.NotebookPath)

// Regular notebook still has its extension stripped on import.
assert.Equal(t,
"/bundle/src/regular",
b.Config.Resources.Jobs["job"].Tasks[1].NotebookTask.NotebookPath)
}

// TestTranslatePathsDesignerNotebookSkipLocalFileValidation verifies the
// designer-suffix preservation also holds on the config-remote-sync code path
// where the local file is not inspected.
func TestTranslatePathsDesignerNotebookSkipLocalFileValidation(t *testing.T) {
dir := t.TempDir()

b := &bundle.Bundle{
SyncRootPath: dir,
BundleRootPath: dir,
SyncRoot: vfs.MustNew(dir),
SkipLocalFileValidation: true,
Config: config.Root{
Workspace: config.Workspace{
FilePath: "/bundle",
},
Resources: config.Resources{
Jobs: map[string]*resources.Job{
"job": {
JobSettings: jobs.JobSettings{
Tasks: []jobs.Task{
{
NotebookTask: &jobs.NotebookTask{
NotebookPath: "./src/designer.designer.ipynb",
},
},
{
NotebookTask: &jobs.NotebookTask{
NotebookPath: "./src/regular.ipynb",
},
},
},
},
},
},
},
},
}

bundletest.SetLocation(b, ".", []dyn.Location{{File: filepath.Join(dir, "databricks.yml")}})

diags := bundle.ApplySeq(t.Context(), b, mutator.NormalizePaths(), mutator.TranslatePaths())
require.NoError(t, diags.Error())

assert.Equal(t,
"/bundle/src/designer.designer.ipynb",
b.Config.Resources.Jobs["job"].Tasks[0].NotebookTask.NotebookPath)
assert.Equal(t,
"/bundle/src/regular",
b.Config.Resources.Jobs["job"].Tasks[1].NotebookTask.NotebookPath)
}
21 changes: 20 additions & 1 deletion libs/notebook/ext.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package notebook

import "github.com/databricks/databricks-sdk-go/service/workspace"
import (
"path"
"strings"

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

const (
ExtensionNone string = ""
Expand All @@ -9,8 +14,22 @@ const (
ExtensionScala string = ".scala"
ExtensionSql string = ".sql"
ExtensionJupyter string = ".ipynb"
// ExtensionDesigner is the compound suffix for Lakeflow Designer files.
// Unlike other notebook types, designer files keep this full suffix when
// imported into the workspace.
ExtensionDesigner string = ".designer.ipynb"
)

// 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.
func StripExtension(name string) string {
if strings.HasSuffix(name, ExtensionDesigner) {
return name
}
return strings.TrimSuffix(name, path.Ext(name))
}

// Extensions lists all notebook file extensions.
var Extensions = []string{
ExtensionPython,
Expand Down
32 changes: 32 additions & 0 deletions libs/notebook/ext_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package notebook

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestStripExtension(t *testing.T) {
for _, tc := range []struct {
in string
want string
}{
{"foo.py", "foo"},
{"foo.ipynb", "foo"},
{"foo.r", "foo"},
{"foo.scala", "foo"},
{"foo.sql", "foo"},
{"a/b/c.ipynb", "a/b/c"},

// Designer files keep their full ".designer.ipynb" suffix.
{"foo.designer.ipynb", "foo.designer.ipynb"},
{"a/b/c.designer.ipynb", "a/b/c.designer.ipynb"},

// Files without a known extension are passed through path.Ext;
// the last-segment extension is removed.
{"foo", "foo"},
{"foo.unknown", "foo"},
} {
assert.Equal(t, tc.want, StripExtension(tc.in), "input=%q", tc.in)
}
}
Loading