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

PythonMutator: support omitempty in PyDABs #1513

Merged
merged 12 commits into from
Jul 3, 2024
29 changes: 29 additions & 0 deletions bundle/config/mutator/python/python_mutator.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,10 @@ func createLoadOverrideVisitor(ctx context.Context) merge.OverrideVisitor {

return merge.OverrideVisitor{
VisitDelete: func(valuePath dyn.Path, left dyn.Value) error {
if isOmitemptyDelete(left) {
return merge.ErrOverrideUndoDelete
}

return fmt.Errorf("unexpected change at %q (delete)", valuePath.String())
},
VisitInsert: func(valuePath dyn.Path, right dyn.Value) (dyn.Value, error) {
Expand Down Expand Up @@ -346,6 +350,10 @@ func createInitOverrideVisitor(ctx context.Context) merge.OverrideVisitor {

return merge.OverrideVisitor{
VisitDelete: func(valuePath dyn.Path, left dyn.Value) error {
if isOmitemptyDelete(left) {
return merge.ErrOverrideUndoDelete
}

if !valuePath.HasPrefix(jobsPath) {
return fmt.Errorf("unexpected change at %q (delete)", valuePath.String())
}
Expand Down Expand Up @@ -382,6 +390,27 @@ func createInitOverrideVisitor(ctx context.Context) merge.OverrideVisitor {
}
}

func isOmitemptyDelete(left dyn.Value) bool {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

omitempty is specific to the JSON serializer. Here we check if the incoming value is an empty collection.

Maybe isEmptyCollection?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was intentional because we want to reserve this method to specific behavior with omit empty and add a comment explaining that. For instance, we intentionally don't handle objects where all fields are empty.

// PyDABs can omit empty sequences/mappings in output, because we don't track them as optional,
// there is no semantic difference between empty and missing, so we keep them as they were before
// PyDABs deleted them.

if left.Kind() == dyn.KindMap && left.MustMap().Len() == 0 {
return true
}

if left.Kind() == dyn.KindSequence && len(left.MustSequence()) == 0 {
return true
}

// map/sequence can be nil, for instance, bad YAML like: `foo:<eof>`
if left.Kind() == dyn.KindNil {
return true
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A switch/case would be cleaner here, IMO.

return false
}

// interpreterPath returns platform-specific path to Python interpreter in the virtual environment.
func interpreterPath(venvPath string) string {
if runtime.GOOS == "windows" {
Expand Down
87 changes: 87 additions & 0 deletions bundle/config/mutator/python/python_mutator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"runtime"
"testing"

"github.com/databricks/cli/libs/dyn/merge"

"github.com/databricks/cli/bundle/env"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -419,6 +421,91 @@ func TestCreateOverrideVisitor(t *testing.T) {
}
}

type overrideVisitorOmitemptyTestCase struct {
name string
path dyn.Path
left dyn.Value
phases []phase
expectedErr error
}

func TestCreateOverrideVisitor_omitempty(t *testing.T) {
// PyDABs can omit empty sequences/mappings in output, because we don't track them as optional,
// there is no semantic difference between empty and missing, so we keep them as they were before
// PyDABs deleted them.

allPhases := []phase{PythonMutatorPhaseLoad, PythonMutatorPhaseInit}
location := dyn.Location{
File: "databricks.yml",
Line: 10,
Column: 20,
}

testCases := []overrideVisitorOmitemptyTestCase{
{
// this is not happening, but adding for completeness
name: "undo delete of empty variables",
path: dyn.MustPathFromString("variables"),
left: dyn.NewValue([]dyn.Value{}, location),
expectedErr: merge.ErrOverrideUndoDelete,
phases: allPhases,
},
{
name: "undo delete of empty job clusters",
path: dyn.MustPathFromString("resources.jobs.job0.job_clusters"),
left: dyn.NewValue([]dyn.Value{}, location),
expectedErr: merge.ErrOverrideUndoDelete,
phases: allPhases,
},
{
name: "allow delete of non-empty job clusters",
path: dyn.MustPathFromString("resources.jobs.job0.job_clusters"),
left: dyn.NewValue([]dyn.Value{dyn.NewValue("abc", location)}, location),
expectedErr: nil,
// deletions aren't allowed in 'load' phase
phases: []phase{PythonMutatorPhaseInit},
},
{
name: "undo delete of empty tags",
path: dyn.MustPathFromString("resources.jobs.job0.tags"),
left: dyn.NewValue(map[string]dyn.Value{}, location),
expectedErr: merge.ErrOverrideUndoDelete,
phases: allPhases,
},
{
name: "allow delete of non-empty tags",
path: dyn.MustPathFromString("resources.jobs.job0.tags"),
left: dyn.NewValue(
map[string]dyn.Value{"dev": dyn.NewValue("true", location)},
location,
),
expectedErr: nil,
// deletions aren't allowed in 'load' phase
phases: []phase{PythonMutatorPhaseInit},
},
{
name: "undo delete of nil",
path: dyn.MustPathFromString("resources.jobs.job0.tags"),
left: dyn.NilValue.WithLocation(location),
expectedErr: merge.ErrOverrideUndoDelete,
phases: allPhases,
},
}

for _, tc := range testCases {
for _, phase := range tc.phases {
t.Run(tc.name+"-"+string(phase), func(t *testing.T) {
visitor, err := createOverrideVisitor(context.Background(), phase)
require.NoError(t, err)

err = visitor.VisitDelete(tc.path, tc.left)

assert.Equal(t, tc.expectedErr, err)
})
}
}
}

func TestLoadDiagnosticsFile_nonExistent(t *testing.T) {
// this is an important behaviour, see loadDiagnosticsFile docstring
_, err := loadDiagnosticsFile("non_existent_file.json")
Expand Down
20 changes: 18 additions & 2 deletions libs/dyn/merge/override.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package merge

import (
"errors"
"fmt"

"github.com/databricks/cli/libs/dyn"
Expand All @@ -13,6 +14,9 @@ import (
// For instance, it can disallow changes outside the specific path(s), or update
// the location of the effective value.
//
// Values returned by 'VisitInsert' and 'VisitUpdate' are used as the final value
// of the node. 'VisitDelete' can return ErrOverrideUndoDelete to undo delete.
//
// 'VisitDelete' is called when a value is removed from mapping or sequence
// 'VisitInsert' is called when a new value is added to mapping or sequence
// 'VisitUpdate' is called when a leaf value is updated
Expand All @@ -22,6 +26,8 @@ type OverrideVisitor struct {
VisitUpdate func(valuePath dyn.Path, left dyn.Value, right dyn.Value) (dyn.Value, error)
}

var ErrOverrideUndoDelete = errors.New("undo delete operation")

// Override overrides value 'leftRoot' with 'rightRoot', keeping 'location' if values
// haven't changed. Preserving 'location' is important to preserve the original source of the value
// for error reporting.
Expand Down Expand Up @@ -111,7 +117,14 @@ func overrideMapping(basePath dyn.Path, leftMapping dyn.Mapping, rightMapping dy

err := visitor.VisitDelete(path, leftPair.Value)

if err != nil {
// if 'delete' was undone, add it back
if errors.Is(err, ErrOverrideUndoDelete) {
err := out.Set(leftPair.Key, leftPair.Value)

if err != nil {
kanterov marked this conversation as resolved.
Show resolved Hide resolved
return dyn.NewMapping(), err
}
} else if err != nil {
return dyn.NewMapping(), err
}
}
Expand Down Expand Up @@ -186,7 +199,10 @@ func overrideSequence(basePath dyn.Path, left []dyn.Value, right []dyn.Value, vi
path := basePath.Append(dyn.Index(i))
err := visitor.VisitDelete(path, left[i])

if err != nil {
// if 'delete' was undone, add it back
if errors.Is(err, ErrOverrideUndoDelete) {
values = append(values, left[i])
} else if err != nil {
return nil, err
}
}
Expand Down
29 changes: 28 additions & 1 deletion libs/dyn/merge/override_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/databricks/cli/libs/dyn"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
Expand Down Expand Up @@ -393,6 +395,24 @@ func TestOverride_Primitive(t *testing.T) {
assert.Equal(t, expected, actual)
}
})

if len(tc.state.removed) > 0 {
t.Run(tc.name+" - visitor can undo delete", func(t *testing.T) {
s, visitor := createVisitor(visitorOpts{deleteError: ErrOverrideUndoDelete})
out, err := override(dyn.EmptyPath, tc.left, tc.right, visitor)
require.NoError(t, err)

for _, removed := range s.removed {
expected, err := dyn.GetByPath(tc.left, dyn.MustPathFromString(removed))
require.NoError(t, err)

actual, err := dyn.GetByPath(out, dyn.MustPathFromString(removed))

assert.NoError(t, err)
assert.Equal(t, expected, actual)
}
})
}
}
}
}
Expand Down Expand Up @@ -449,6 +469,7 @@ type visitorState struct {

type visitorOpts struct {
error error
deleteError error
returnValue *dyn.Value
}

Expand All @@ -470,7 +491,13 @@ func createVisitor(opts visitorOpts) (*visitorState, OverrideVisitor) {
VisitDelete: func(valuePath dyn.Path, left dyn.Value) error {
s.removed = append(s.removed, valuePath.String())

return opts.error
if opts.error != nil {
return opts.error
} else if opts.deleteError != nil {
return opts.deleteError
} else {
return nil
}
},
VisitInsert: func(valuePath dyn.Path, right dyn.Value) (dyn.Value, error) {
s.added = append(s.added, valuePath.String())
Expand Down