mirror of https://github.com/databricks/cli.git
Add support for conditional prompting in bundle init (#971)
## Changes This PR introduces the `skip_prompt_if` extension to the jsonschema library. If the inputs provided by the user match the JSON schema then the prompt for that property is skipped. Right now only constant checks are supported, but if in the future more complicated conditionals are required, this can be extended to support `allOf`, `oneOf`, `anyOf` etc allowing template authors to specify conditionals of arbitary complexity. ## Tests Unit tests and manually.
This commit is contained in:
parent
1f1ed6db53
commit
bdef0f7b23
|
@ -27,6 +27,10 @@ type Extension struct {
|
||||||
// schema will fail.
|
// schema will fail.
|
||||||
MinDatabricksCliVersion string `json:"min_databricks_cli_version,omitempty"`
|
MinDatabricksCliVersion string `json:"min_databricks_cli_version,omitempty"`
|
||||||
|
|
||||||
|
// Skip prompting if this schema is satisfied by the configuration already present. In
|
||||||
|
// that case the default value of the property is used instead.
|
||||||
|
SkipPromptIf *Schema `json:"skip_prompt_if,omitempty"`
|
||||||
|
|
||||||
// Version of the schema. This is used to determine if the schema is
|
// Version of the schema. This is used to determine if the schema is
|
||||||
// compatible with the current CLI version.
|
// compatible with the current CLI version.
|
||||||
Version *int `json:"version,omitempty"`
|
Version *int `json:"version,omitempty"`
|
||||||
|
|
|
@ -20,6 +20,10 @@ type Schema struct {
|
||||||
// IDE. This is manually injected here using schema.Docs
|
// IDE. This is manually injected here using schema.Docs
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
|
|
||||||
|
// Expected value for the JSON object. The object value must be equal to this
|
||||||
|
// field if it's specified in the schema.
|
||||||
|
Const any `json:"const,omitempty"`
|
||||||
|
|
||||||
// Schemas for the fields of an struct. The keys are the first json tag.
|
// Schemas for the fields of an struct. The keys are the first json tag.
|
||||||
// The values are the schema for the type of the field
|
// The values are the schema for the type of the field
|
||||||
Properties map[string]*Schema `json:"properties,omitempty"`
|
Properties map[string]*Schema `json:"properties,omitempty"`
|
||||||
|
@ -118,6 +122,18 @@ func (schema *Schema) validateSchemaDefaultValueTypes() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (schema *Schema) validateConstValueTypes() error {
|
||||||
|
for name, property := range schema.Properties {
|
||||||
|
if property.Const == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateType(property.Const, property.Type); err != nil {
|
||||||
|
return fmt.Errorf("type validation for const value of property %s failed: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Validate enum field values for properties are consistent with types.
|
// Validate enum field values for properties are consistent with types.
|
||||||
func (schema *Schema) validateSchemaEnumValueTypes() error {
|
func (schema *Schema) validateSchemaEnumValueTypes() error {
|
||||||
for name, property := range schema.Properties {
|
for name, property := range schema.Properties {
|
||||||
|
@ -203,14 +219,25 @@ func (schema *Schema) validateSchemaMinimumCliVersion(currentVersion string) fun
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (schema *Schema) validateSchemaSkippedPropertiesHaveDefaults() error {
|
||||||
|
for name, property := range schema.Properties {
|
||||||
|
if property.SkipPromptIf != nil && property.Default == nil {
|
||||||
|
return fmt.Errorf("property %q has a skip_prompt_if clause but no default value", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (schema *Schema) validate() error {
|
func (schema *Schema) validate() error {
|
||||||
for _, fn := range []func() error{
|
for _, fn := range []func() error{
|
||||||
schema.validateSchemaPropertyTypes,
|
schema.validateSchemaPropertyTypes,
|
||||||
schema.validateSchemaDefaultValueTypes,
|
schema.validateSchemaDefaultValueTypes,
|
||||||
schema.validateSchemaEnumValueTypes,
|
schema.validateSchemaEnumValueTypes,
|
||||||
|
schema.validateConstValueTypes,
|
||||||
schema.validateSchemaDefaultValueIsInEnums,
|
schema.validateSchemaDefaultValueIsInEnums,
|
||||||
schema.validateSchemaPattern,
|
schema.validateSchemaPattern,
|
||||||
schema.validateSchemaMinimumCliVersion("v" + build.GetInfo().Version),
|
schema.validateSchemaMinimumCliVersion("v" + build.GetInfo().Version),
|
||||||
|
schema.validateSchemaSkippedPropertiesHaveDefaults,
|
||||||
} {
|
} {
|
||||||
err := fn()
|
err := fn()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -248,6 +275,12 @@ func Load(path string) (*Schema, error) {
|
||||||
return nil, fmt.Errorf("failed to parse default value for property %s: %w", name, err)
|
return nil, fmt.Errorf("failed to parse default value for property %s: %w", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if property.Const != nil {
|
||||||
|
property.Const, err = toInteger(property.Const)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse const value for property %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
for i, enum := range property.Enum {
|
for i, enum := range property.Enum {
|
||||||
property.Enum[i], err = toInteger(enum)
|
property.Enum[i], err = toInteger(enum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -48,6 +48,7 @@ func TestSchemaLoadIntegers(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, int64(1), schema.Properties["abc"].Default)
|
assert.Equal(t, int64(1), schema.Properties["abc"].Default)
|
||||||
assert.Equal(t, []any{int64(1), int64(2), int64(3)}, schema.Properties["abc"].Enum)
|
assert.Equal(t, []any{int64(1), int64(2), int64(3)}, schema.Properties["abc"].Enum)
|
||||||
|
assert.Equal(t, int64(5), schema.Properties["def"].Const)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSchemaLoadIntegersWithInvalidDefault(t *testing.T) {
|
func TestSchemaLoadIntegersWithInvalidDefault(t *testing.T) {
|
||||||
|
@ -60,6 +61,11 @@ func TestSchemaLoadIntegersWithInvalidEnums(t *testing.T) {
|
||||||
assert.EqualError(t, err, "failed to parse enum value 2.4 at index 1 for property abc: expected integer value, got: 2.4")
|
assert.EqualError(t, err, "failed to parse enum value 2.4 at index 1 for property abc: expected integer value, got: 2.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchemaLoadIntergersWithInvalidConst(t *testing.T) {
|
||||||
|
_, err := Load("./testdata/schema-load-int/schema-invalid-const.json")
|
||||||
|
assert.EqualError(t, err, "failed to parse const value for property def: expected integer value, got: 5.1")
|
||||||
|
}
|
||||||
|
|
||||||
func TestSchemaValidateDefaultType(t *testing.T) {
|
func TestSchemaValidateDefaultType(t *testing.T) {
|
||||||
invalidSchema := &Schema{
|
invalidSchema := &Schema{
|
||||||
Properties: map[string]*Schema{
|
Properties: map[string]*Schema{
|
||||||
|
@ -250,3 +256,52 @@ func TestValidateSchemaMinimumCliVersion(t *testing.T) {
|
||||||
err = s.validateSchemaMinimumCliVersion("v0.0.0-dev")()
|
err = s.validateSchemaMinimumCliVersion("v0.0.0-dev")()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateSchemaConstTypes(t *testing.T) {
|
||||||
|
s := &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
Const: "abc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := s.validate()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
s = &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
Const: 123,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err = s.validate()
|
||||||
|
assert.EqualError(t, err, "type validation for const value of property foo failed: expected type string, but value is 123")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSchemaSkippedPropertiesHaveDefaults(t *testing.T) {
|
||||||
|
s := &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
Extension: Extension{SkipPromptIf: &Schema{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err := s.validate()
|
||||||
|
assert.EqualError(t, err, "property \"foo\" has a skip_prompt_if clause but no default value")
|
||||||
|
|
||||||
|
s = &Schema{
|
||||||
|
Properties: map[string]*Schema{
|
||||||
|
"foo": {
|
||||||
|
Type: "string",
|
||||||
|
Default: "abc",
|
||||||
|
Extension: Extension{SkipPromptIf: &Schema{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
err = s.validate()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"def": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 5.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,10 @@
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"default": 1,
|
"default": 1,
|
||||||
"enum": [1,2,3]
|
"enum": [1,2,3]
|
||||||
|
},
|
||||||
|
"def": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -105,20 +105,61 @@ func (c *config) assignDefaultValues(r *renderer) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *config) skipPrompt(p jsonschema.Property, r *renderer) (bool, error) {
|
||||||
|
// Config already has a value assigned. We don't have to prompt for a user input.
|
||||||
|
if _, ok := c.values[p.Name]; ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Schema.SkipPromptIf == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if conditions specified by template author for skipping the prompt
|
||||||
|
// are satisfied. If they are not, we have to prompt for a user input.
|
||||||
|
for name, property := range p.Schema.SkipPromptIf.Properties {
|
||||||
|
if v, ok := c.values[name]; ok && v == property.Const {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Schema.Default == nil {
|
||||||
|
return false, fmt.Errorf("property %s has skip_prompt_if set but no default value", p.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign default value to property if we are skipping it.
|
||||||
|
if p.Schema.Type != jsonschema.StringType {
|
||||||
|
c.values[p.Name] = p.Schema.Default
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the default value as a template and assign it to the property.
|
||||||
|
var err error
|
||||||
|
c.values[p.Name], err = r.executeTemplate(p.Schema.Default.(string))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Prompts user for values for properties that do not have a value set yet
|
// Prompts user for values for properties that do not have a value set yet
|
||||||
func (c *config) promptForValues(r *renderer) error {
|
func (c *config) promptForValues(r *renderer) error {
|
||||||
for _, p := range c.schema.OrderedProperties() {
|
for _, p := range c.schema.OrderedProperties() {
|
||||||
name := p.Name
|
name := p.Name
|
||||||
property := p.Schema
|
property := p.Schema
|
||||||
|
|
||||||
// Config already has a value assigned
|
// Skip prompting if we can.
|
||||||
if _, ok := c.values[name]; ok {
|
skip, err := c.skipPrompt(p, r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if skip {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute default value to display by converting it to a string
|
// Compute default value to display by converting it to a string
|
||||||
var defaultVal string
|
var defaultVal string
|
||||||
var err error
|
|
||||||
if property.Default != nil {
|
if property.Default != nil {
|
||||||
defaultValRaw, err := property.DefaultString()
|
defaultValRaw, err := property.DefaultString()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
"github.com/databricks/cli/cmd/root"
|
"github.com/databricks/cli/cmd/root"
|
||||||
"github.com/databricks/cli/libs/jsonschema"
|
"github.com/databricks/cli/libs/jsonschema"
|
||||||
|
@ -229,3 +230,171 @@ func TestTemplateSchemaErrorsWithEmptyDescription(t *testing.T) {
|
||||||
_, err := newConfig(context.Background(), "./testdata/config-test-schema/invalid-test-schema.json")
|
_, err := newConfig(context.Background(), "./testdata/config-test-schema/invalid-test-schema.json")
|
||||||
assert.EqualError(t, err, "template property property-without-description is missing a description")
|
assert.EqualError(t, err, "template property property-without-description is missing a description")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testRenderer() *renderer {
|
||||||
|
return &renderer{
|
||||||
|
config: map[string]any{
|
||||||
|
"fruit": "apples",
|
||||||
|
},
|
||||||
|
baseTemplate: template.New(""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptIsSkippedWhenEmpty(t *testing.T) {
|
||||||
|
c := config{
|
||||||
|
ctx: context.Background(),
|
||||||
|
values: make(map[string]any),
|
||||||
|
schema: &jsonschema.Schema{
|
||||||
|
Properties: map[string]*jsonschema.Schema{
|
||||||
|
"always-skip": {
|
||||||
|
Type: "string",
|
||||||
|
Default: "I like {{.fruit}}",
|
||||||
|
Extension: jsonschema.Extension{
|
||||||
|
SkipPromptIf: &jsonschema.Schema{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// We should always skip the prompt here. An empty JSON schema by definition
|
||||||
|
// matches all possible configurations.
|
||||||
|
skip, err := c.skipPrompt(jsonschema.Property{
|
||||||
|
Name: "always-skip",
|
||||||
|
Schema: c.schema.Properties["always-skip"],
|
||||||
|
}, testRenderer())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, skip)
|
||||||
|
assert.Equal(t, "I like apples", c.values["always-skip"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptSkipErrorsWithEmptyDefault(t *testing.T) {
|
||||||
|
c := config{
|
||||||
|
ctx: context.Background(),
|
||||||
|
values: make(map[string]any),
|
||||||
|
schema: &jsonschema.Schema{
|
||||||
|
Properties: map[string]*jsonschema.Schema{
|
||||||
|
"no-default": {
|
||||||
|
Type: "string",
|
||||||
|
Extension: jsonschema.Extension{
|
||||||
|
SkipPromptIf: &jsonschema.Schema{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := c.skipPrompt(jsonschema.Property{
|
||||||
|
Name: "no-default",
|
||||||
|
Schema: c.schema.Properties["no-default"],
|
||||||
|
}, testRenderer())
|
||||||
|
assert.EqualError(t, err, "property no-default has skip_prompt_if set but no default value")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptIsSkippedIfValueIsAssigned(t *testing.T) {
|
||||||
|
c := config{
|
||||||
|
ctx: context.Background(),
|
||||||
|
values: make(map[string]any),
|
||||||
|
schema: &jsonschema.Schema{
|
||||||
|
Properties: map[string]*jsonschema.Schema{
|
||||||
|
"already-assigned": {
|
||||||
|
Type: "string",
|
||||||
|
Default: "some-default-value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.values["already-assigned"] = "some-value"
|
||||||
|
skip, err := c.skipPrompt(jsonschema.Property{
|
||||||
|
Name: "already-assigned",
|
||||||
|
Schema: c.schema.Properties["already-assigned"],
|
||||||
|
}, testRenderer())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, skip)
|
||||||
|
assert.Equal(t, "some-value", c.values["already-assigned"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromptIsSkipped(t *testing.T) {
|
||||||
|
c := config{
|
||||||
|
ctx: context.Background(),
|
||||||
|
values: make(map[string]any),
|
||||||
|
schema: &jsonschema.Schema{
|
||||||
|
Properties: map[string]*jsonschema.Schema{
|
||||||
|
"abc": {
|
||||||
|
Type: "string",
|
||||||
|
},
|
||||||
|
"def": {
|
||||||
|
Type: "integer",
|
||||||
|
},
|
||||||
|
"xyz": {
|
||||||
|
Type: "string",
|
||||||
|
Default: "hello-world",
|
||||||
|
Extension: jsonschema.Extension{
|
||||||
|
SkipPromptIf: &jsonschema.Schema{
|
||||||
|
Properties: map[string]*jsonschema.Schema{
|
||||||
|
"abc": {
|
||||||
|
Const: "foobar",
|
||||||
|
},
|
||||||
|
"def": {
|
||||||
|
Const: 123,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// No skip condition defined. Prompt should not be skipped.
|
||||||
|
skip, err := c.skipPrompt(jsonschema.Property{
|
||||||
|
Name: "abc",
|
||||||
|
Schema: c.schema.Properties["abc"],
|
||||||
|
}, testRenderer())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, skip)
|
||||||
|
|
||||||
|
// No values assigned to config. Prompt should not be skipped.
|
||||||
|
skip, err = c.skipPrompt(jsonschema.Property{
|
||||||
|
Name: "xyz",
|
||||||
|
Schema: c.schema.Properties["xyz"],
|
||||||
|
}, testRenderer())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, skip)
|
||||||
|
assert.NotContains(t, c.values, "xyz")
|
||||||
|
|
||||||
|
// Values do not match skip condition. Prompt should not be skipped.
|
||||||
|
c.values["abc"] = "foo"
|
||||||
|
c.values["def"] = 123
|
||||||
|
skip, err = c.skipPrompt(jsonschema.Property{
|
||||||
|
Name: "xyz",
|
||||||
|
Schema: c.schema.Properties["xyz"],
|
||||||
|
}, testRenderer())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, skip)
|
||||||
|
assert.NotContains(t, c.values, "xyz")
|
||||||
|
|
||||||
|
// Values do not match skip condition. Prompt should not be skipped.
|
||||||
|
c.values["abc"] = "foobar"
|
||||||
|
c.values["def"] = 1234
|
||||||
|
skip, err = c.skipPrompt(jsonschema.Property{
|
||||||
|
Name: "xyz",
|
||||||
|
Schema: c.schema.Properties["xyz"],
|
||||||
|
}, testRenderer())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, skip)
|
||||||
|
assert.NotContains(t, c.values, "xyz")
|
||||||
|
|
||||||
|
// Values match skip condition. Prompt should be skipped. Default value should
|
||||||
|
// be assigned to "xyz".
|
||||||
|
c.values["abc"] = "foobar"
|
||||||
|
c.values["def"] = 123
|
||||||
|
skip, err = c.skipPrompt(jsonschema.Property{
|
||||||
|
Name: "xyz",
|
||||||
|
Schema: c.schema.Properties["xyz"],
|
||||||
|
}, testRenderer())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, skip)
|
||||||
|
assert.Equal(t, "hello-world", c.values["xyz"])
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue