Add support for reprompts if user input does not match template schema (#946)

## Changes
This PR adds retry logic to user input prompts, prompting users again if
the value does not match the requirements specified in the bundle
template schema.

## Tests
Manually. Here's an example UX. The first prompt expects an integer and
the second one a string made only from the letters "defg"

```
shreyas.goenka@THW32HFW6T cli % cli bundle init ~/mlops-stack

Please enter an integer [123]: abc
Validation failed: "abc" is not a integer

Please enter an integer [123]: 123

Please enter a string [dddd]: apple
Validation failed: invalid value for input_root_dir: "apple". Only characters the 'd', 'e', 'f', 'g' are allowed
```
This commit is contained in:
shreyas-goenka 2023-12-22 21:13:08 +05:30 committed by GitHub
parent fa3c8b1017
commit f2408eda62
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 107 additions and 34 deletions

View File

@ -7,6 +7,19 @@ import (
"strconv"
)
// This error indicates an failure to parse a string as a particular JSON schema type.
type parseStringError struct {
// Expected JSON schema type for the value
ExpectedType Type
// The string value that failed to parse
Value string
}
func (e parseStringError) Error() string {
return fmt.Sprintf("%q is not a %s", e.Value, e.ExpectedType)
}
// function to check whether a float value represents an integer
func isIntegerValue(v float64) bool {
return v == float64(int64(v))
@ -108,11 +121,40 @@ func fromString(s string, T Type) (any, error) {
// Return more readable error incase of a syntax error
if errors.Is(err, strconv.ErrSyntax) {
return nil, fmt.Errorf("could not parse %q as a %s: %w", s, T, err)
return nil, parseStringError{
ExpectedType: T,
Value: s,
}
}
return v, err
}
// Error indicates a value entered by the user failed to match the pattern specified
// in the template schema.
type patternMatchError struct {
// The name of the property that failed to match the pattern
PropertyName string
// The value of the property that failed to match the pattern
PropertyValue any
// The regex pattern that the property value failed to match
Pattern string
// Failure message to display to the user, if specified in the template
// schema
FailureMessage string
}
func (e patternMatchError) Error() string {
// If custom user error message is defined, return error with the custom message
msg := e.FailureMessage
if msg == "" {
msg = fmt.Sprintf("Expected to match regex pattern: %s", e.Pattern)
}
return fmt.Sprintf("invalid value for %s: %q. %s", e.PropertyName, e.PropertyValue, msg)
}
func validatePatternMatch(name string, value any, propertySchema *Schema) error {
if propertySchema.Pattern == "" {
// Return early if no pattern is specified
@ -134,10 +176,10 @@ func validatePatternMatch(name string, value any, propertySchema *Schema) error
return nil
}
// If custom user error message is defined, return error with the custom message
msg := propertySchema.PatternMatchFailureMessage
if msg == "" {
msg = fmt.Sprintf("Expected to match regex pattern: %s", propertySchema.Pattern)
return patternMatchError{
PropertyName: name,
PropertyValue: value,
Pattern: propertySchema.Pattern,
FailureMessage: propertySchema.PatternMatchFailureMessage,
}
return fmt.Errorf("invalid value for %s: %q. %s", name, value, msg)
}

View File

@ -110,10 +110,10 @@ func TestTemplateFromString(t *testing.T) {
assert.EqualError(t, err, "cannot parse string as object of type array. Value of string: \"qrt\"")
_, err = fromString("abc", IntegerType)
assert.EqualError(t, err, "could not parse \"abc\" as a integer: strconv.ParseInt: parsing \"abc\": invalid syntax")
assert.EqualError(t, err, "\"abc\" is not a integer")
_, err = fromString("1.0", IntegerType)
assert.EqualError(t, err, "could not parse \"1.0\" as a integer: strconv.ParseInt: parsing \"1.0\": invalid syntax")
assert.EqualError(t, err, "\"1.0\" is not a integer")
_, err = fromString("1.0", "foobar")
assert.EqualError(t, err, "unknown json schema type: \"foobar\"")

View File

@ -2,6 +2,7 @@ package template
import (
"context"
"errors"
"fmt"
"github.com/databricks/cli/libs/cmdio"
@ -12,6 +13,14 @@ import (
// The latest template schema version supported by the CLI
const latestSchemaVersion = 1
type retriableError struct {
err error
}
func (e retriableError) Error() string {
return e.err.Error()
}
type config struct {
ctx context.Context
values map[string]any
@ -143,6 +152,45 @@ func (c *config) skipPrompt(p jsonschema.Property, r *renderer) (bool, error) {
return true, nil
}
func (c *config) promptOnce(property *jsonschema.Schema, name, defaultVal, description string) error {
var userInput string
if property.Enum != nil {
// List options for the user to select from
options, err := property.EnumStringSlice()
if err != nil {
return err
}
userInput, err = cmdio.AskSelect(c.ctx, description, options)
if err != nil {
return err
}
} else {
var err error
userInput, err = cmdio.Ask(c.ctx, description, defaultVal)
if err != nil {
return err
}
}
// Convert user input string back to a Go value
var err error
c.values[name], err = property.ParseString(userInput)
if err != nil {
// Show error and retry if validation fails
cmdio.LogString(c.ctx, fmt.Sprintf("Validation failed: %s", err.Error()))
return retriableError{err: err}
}
// Validate the partial config which includes the new value
err = c.schema.ValidateInstance(c.values)
if err != nil {
// Show error and retry if validation fails
cmdio.LogString(c.ctx, fmt.Sprintf("Validation failed: %s", err.Error()))
return retriableError{err: err}
}
return 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() {
@ -171,39 +219,22 @@ func (c *config) promptForValues(r *renderer) error {
}
}
// Compute description for the prompt
description, err := r.executeTemplate(property.Description)
if err != nil {
return err
}
// Get user input by running the prompt
var userInput string
if property.Enum != nil {
// convert list of enums to string slice
enums, err := property.EnumStringSlice()
if err != nil {
// We wrap this function in a retry loop to allow retries when the user
// entered value is invalid.
for {
err = c.promptOnce(property, name, defaultVal, description)
if err == nil {
break
}
if !errors.As(err, &retriableError{}) {
return err
}
userInput, err = cmdio.AskSelect(c.ctx, description, enums)
if err != nil {
return err
}
} else {
userInput, err = cmdio.Ask(c.ctx, description, defaultVal)
if err != nil {
return err
}
}
// Convert user input string back to a value
c.values[name], err = property.ParseString(userInput)
if err != nil {
return err
}
// Validate the partial config based on this update
if err := c.schema.ValidateInstance(c.values); err != nil {
return err
}
}
return nil