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:
shreyas-goenka 2023-11-30 17:07:45 +01:00 committed by GitHub
parent 1f1ed6db53
commit bdef0f7b23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 318 additions and 3 deletions

View File

@ -27,6 +27,10 @@ type Extension struct {
// schema will fail.
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
// compatible with the current CLI version.
Version *int `json:"version,omitempty"`

View File

@ -20,6 +20,10 @@ type Schema struct {
// IDE. This is manually injected here using schema.Docs
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.
// The values are the schema for the type of the field
Properties map[string]*Schema `json:"properties,omitempty"`
@ -118,6 +122,18 @@ func (schema *Schema) validateSchemaDefaultValueTypes() error {
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.
func (schema *Schema) validateSchemaEnumValueTypes() error {
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 {
for _, fn := range []func() error{
schema.validateSchemaPropertyTypes,
schema.validateSchemaDefaultValueTypes,
schema.validateSchemaEnumValueTypes,
schema.validateConstValueTypes,
schema.validateSchemaDefaultValueIsInEnums,
schema.validateSchemaPattern,
schema.validateSchemaMinimumCliVersion("v" + build.GetInfo().Version),
schema.validateSchemaSkippedPropertiesHaveDefaults,
} {
err := fn()
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)
}
}
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 {
property.Enum[i], err = toInteger(enum)
if err != nil {

View File

@ -48,6 +48,7 @@ func TestSchemaLoadIntegers(t *testing.T) {
assert.NoError(t, err)
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, int64(5), schema.Properties["def"].Const)
}
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")
}
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) {
invalidSchema := &Schema{
Properties: map[string]*Schema{
@ -250,3 +256,52 @@ func TestValidateSchemaMinimumCliVersion(t *testing.T) {
err = s.validateSchemaMinimumCliVersion("v0.0.0-dev")()
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)
}

View File

@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"def": {
"type": "integer",
"const": 5.1
}
}
}

View File

@ -5,6 +5,10 @@
"type": "integer",
"default": 1,
"enum": [1,2,3]
},
"def": {
"type": "integer",
"const": 5
}
}
}

View File

@ -105,20 +105,61 @@ func (c *config) assignDefaultValues(r *renderer) error {
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
func (c *config) promptForValues(r *renderer) error {
for _, p := range c.schema.OrderedProperties() {
name := p.Name
property := p.Schema
// Config already has a value assigned
if _, ok := c.values[name]; ok {
// Skip prompting if we can.
skip, err := c.skipPrompt(p, r)
if err != nil {
return err
}
if skip {
continue
}
// Compute default value to display by converting it to a string
var defaultVal string
var err error
if property.Default != nil {
defaultValRaw, err := property.DefaultString()
if err != nil {

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"testing"
"text/template"
"github.com/databricks/cli/cmd/root"
"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")
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"])
}