Skip to content
Merged
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: 0 additions & 1 deletion docs/project-specification/01-authoring-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ x-topo:
<PARAMETER_NAME>:
description: string # Optional
required: boolean # Optional
default: string # Optional
example: string # Optional
```

Expand Down
5 changes: 2 additions & 3 deletions docs/project-specification/02-project-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ services:
platform: linux/arm64
build:
context: .
# Optional default: allows running with plain docker compose
# Not used by Implementations that read x-topo.parameters
# Initial value: allows running with plain docker compose
args:
GREETING: "Hello, World"

Expand Down Expand Up @@ -65,7 +64,7 @@ x-topo:
parameters:
MODEL:
description: "Model artifact reference"
default: "bartowski/Qwen_Qwen3.5-0.8B-GGUF:SmolLM2-135M-Instruct-Q4_K_M.gguf"
example: "bartowski/Qwen_Qwen3.5-0.8B-GGUF:SmolLM2-135M-Instruct-Q4_K_M.gguf"
hints:
huggingface.task: text-generation
file.format: gguf
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@
},
"required": {
"type": "boolean",
"description": "If `true`, Implementations must enforce input or error"
"description": "If `true`, Implementations must enforce that all build args values are set for this parameter when configuring the Project"
},
"default": {
"type": "string",
"description": "Value used if user skips input (only valid when not required)"
"description": "Deprecated. Topo versions >11.0.1 ignore this property."
},
"example": {
"type": "string",
Expand Down
10 changes: 5 additions & 5 deletions internal/arguments/interactive_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,19 @@ func (p *InteractiveProvider) Provide(args []Arg) ([]ResolvedArg, error) {
}
}

if arg.Default != "" {
_, err := fmt.Fprintf(p.output, "Default: %s\n", arg.Default)
if len(arg.CurrentValues) > 0 {
_, err := fmt.Fprintf(p.output, "Current: %s\n", formatCurrentValues(arg.CurrentValues))
if err != nil {
return nil, err
}
}

requiredLabel := ""
label := "optional"
if arg.Required {
requiredLabel = " (required)"
label = "required"
}

_, err = fmt.Fprintf(p.output, "%s%s> ", arg.Name, requiredLabel)
_, err = fmt.Fprintf(p.output, "%s (%s, press Enter to skip)> ", arg.Name, label)
if err != nil {
return nil, err
}
Expand Down
20 changes: 16 additions & 4 deletions internal/arguments/interactive_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,33 @@ func TestInteractiveProvider(t *testing.T) {
assert.Equal(t, want, got)
assert.Contains(t, output.String(), "The greeting message")
assert.Contains(t, output.String(), "Example: Hello")
assert.Contains(t, output.String(), "GREETING (required)>")
assert.Contains(t, output.String(), "GREETING (required, press Enter to skip)>")
})

t.Run("skips empty inputs", func(t *testing.T) {
input := strings.NewReader("\n")
output := &bytes.Buffer{}
provider := arguments.NewInteractiveProvider(input, output)

args := []arguments.Arg{
{Name: "OPTIONAL", Required: false},
}
got, err := provider.Provide([]arguments.Arg{{Name: "OPTIONAL"}})

require.NoError(t, err)
assert.Empty(t, got)
})

t.Run("shows current values", func(t *testing.T) {
input := strings.NewReader("\n")
output := &bytes.Buffer{}
provider := arguments.NewInteractiveProvider(input, output)
args := []arguments.Arg{{
Name: "GREETING",
CurrentValues: []string{"Hello", ""},
}}

got, err := provider.Provide(args)

require.NoError(t, err)
assert.Empty(t, got)
assert.Contains(t, output.String(), `Current: ["Hello",""]`)
})
}
23 changes: 18 additions & 5 deletions internal/arguments/provider.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package arguments

import (
"encoding/json"
"fmt"
)

type Arg struct {
Name string
Description string
Required bool
Example string
Default string
Name string
Description string
Required bool
Example string
CurrentValues []string
}

type ResolvedArg struct {
Expand All @@ -16,3 +21,11 @@ type ResolvedArg struct {
type Provider interface {
Provide(args []Arg) ([]ResolvedArg, error)
}

func formatCurrentValues(values []string) string {
formatted, err := json.Marshal(values)
if err != nil {
return fmt.Sprintf("%q", values)
}
return string(formatted)
}
49 changes: 24 additions & 25 deletions internal/arguments/strict_provider_chain.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package arguments

import "strings"

import "fmt"
import (
"fmt"
"slices"
"strings"
)

// StrictProviderChain chains multiple providers and ensures all required arguments are resolved.
// It stops early once all required arguments are satisfied.
Expand Down Expand Up @@ -34,16 +36,12 @@ func (p *StrictProviderChain) Provide(args []Arg) ([]ResolvedArg, error) {

remaining = filterProvided(remaining, provided)

if allRequiredProvided(args, provided) {
if allRequiredResolved(args, provided) {
break
}
}

if len(remaining) > 0 {
defaultNonProvided(remaining, provided)
}

if err := validateRequiredProvided(args, provided); err != nil {
if err := validateRequiredResolved(args, provided); err != nil {
return nil, err
}

Expand All @@ -61,13 +59,16 @@ type MissingArgsError []Arg

func (e MissingArgsError) Error() string {
var msg strings.Builder
msg.WriteString("missing required parameters:\n")
msg.WriteString("missing value(s) for required parameters:\n")
for _, arg := range e {
fmt.Fprintf(&msg, " %s:\n", arg.Name)
fmt.Fprintf(&msg, " description: %s\n", arg.Description)
if arg.Example != "" {
fmt.Fprintf(&msg, " example: %s\n", arg.Example)
}
if len(arg.CurrentValues) > 0 {
fmt.Fprintf(&msg, " # current: %s\n", formatCurrentValues(arg.CurrentValues))
}
}
return msg.String()
}
Expand All @@ -82,32 +83,30 @@ func filterProvided(args []Arg, provided map[string]string) []Arg {
return remaining
}

func allRequiredProvided(args []Arg, provided map[string]string) bool {
func allRequiredResolved(args []Arg, provided map[string]string) bool {
for _, arg := range args {
if arg.Required {
if value, exists := provided[arg.Name]; !exists || value == "" {
return false
}
if arg.Required && !isResolved(arg, provided) {
return false
}
}
return true
}

func defaultNonProvided(remaining []Arg, provided map[string]string) {
for _, arg := range remaining {
if arg.Default != "" {
provided[arg.Name] = arg.Default
}
func isResolved(arg Arg, provided map[string]string) bool {
if value, exists := provided[arg.Name]; exists {
return value != ""
}
if len(arg.CurrentValues) == 0 {
return false
}
return !slices.Contains(arg.CurrentValues, "")
}

func validateRequiredProvided(args []Arg, provided map[string]string) error {
func validateRequiredResolved(args []Arg, provided map[string]string) error {
var missing []Arg
for _, arg := range args {
if arg.Required {
if value, exists := provided[arg.Name]; !exists || value == "" {
missing = append(missing, arg)
}
if arg.Required && !isResolved(arg, provided) {
missing = append(missing, arg)
}
}

Expand Down
45 changes: 28 additions & 17 deletions internal/arguments/strict_provider_chain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,27 +160,36 @@ func TestStrictMultiProvider(t *testing.T) {
assert.Equal(t, want, got)
})

t.Run("provides resolved args when default provided", func(t *testing.T) {
provider1 := arguments.NewStaticProvider()
multi := arguments.NewStrictProviderChain(provider1)
args := []arguments.Arg{
{
Name: "CINNAMON",
Required: true,
Default: "filled",
},
}
t.Run("allows required arguments with non-empty current values", func(t *testing.T) {
provider := arguments.NewStaticProvider()
multi := arguments.NewStrictProviderChain(provider)
args := []arguments.Arg{{
Name: "CINNAMON",
Required: true,
CurrentValues: []string{"current", "${CINNAMON}"},
}}

got, err := multi.Provide(args)

require.NoError(t, err)
want := []arguments.ResolvedArg{
{Name: "CINNAMON", Value: "filled"},
assert.Empty(t, got)
})

t.Run("errors when any current value is empty", func(t *testing.T) {
provider := arguments.NewStaticProvider()
multi := arguments.NewStrictProviderChain(provider)
arg := arguments.Arg{
Name: "CINNAMON",
Required: true,
CurrentValues: []string{"current", ""},
}
assert.Equal(t, want, got)

_, err := multi.Provide([]arguments.Arg{arg})

assert.Equal(t, arguments.MissingArgsError{arg}, err)
})

t.Run("does not provide resolved args when no default provided", func(t *testing.T) {
t.Run("does not resolve omitted optional arguments", func(t *testing.T) {
provider1 := arguments.NewStaticProvider()
multi := arguments.NewStrictProviderChain(provider1)
args := []arguments.Arg{
Expand All @@ -207,19 +216,21 @@ func TestMissingArgsError(t *testing.T) {
Example: "Hello",
},
{
Name: "PORT",
Description: "Port number",
Name: "PORT",
Description: "Port number",
CurrentValues: []string{"8080", ""},
},
}

got := err.Error()

want := `missing required parameters:
want := `missing value(s) for required parameters:
GREETING:
description: The greeting message
example: Hello
PORT:
description: Port number
# current: ["8080",""]
`
assert.Equal(t, want, got)
})
Expand Down
Loading
Loading