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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ notte functions create --file workflow.py --response-format @schema.json # ...
notte functions update --file workflow.py # Update current function code
notte functions update --file workflow.py --response-format @schema.json # ... and re-document its response
notte functions configure --run-instructions "..." --self-healing # Set usage notes and self-healing
notte functions configure --response-format @schema.json # Document run()'s return schema without re-upload
notte functions rollback --version <version> # Restore an earlier version (see `versions` in show)
notte functions health # Runtime health: Python version, installed packages, reachability
notte functions delete # Delete current function
Expand Down Expand Up @@ -241,6 +242,8 @@ return model:
```bash
python -c 'import json, typing, client; print(json.dumps(typing.get_type_hints(client.run)["return"].model_json_schema()))' > schema.json
notte functions create --file client.py --response-format @schema.json
# Or document it later without re-uploading the code:
notte functions configure --response-format @schema.json
```

`--run-instructions` is documentation for whoever *calls* the function — how long a
Expand Down
5 changes: 4 additions & 1 deletion internal/api/client.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions internal/cmd/functionconfigure_flags.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions internal/cmd/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,12 @@ var functionsConfigureCmd = &cobra.Command{
Use: "configure",
Short: "Update function metadata",
Long: "Update a function's metadata.\n\n" +
"Pass any of --name, --description, --domain, --run-instructions, or --self-healing. " +
"Pass any of --name, --description, --domain, --run-instructions, --response-format, or --self-healing. " +
"Only the flags you pass are sent; omitted fields are left unchanged.\n\n" +
"--run-instructions is documentation for whoever calls the function - how long a " +
"run takes, what each variable is for, which sites it trips over. It is not " +
"input to the self-healing agent.",
"input to the self-healing agent.\n\n" +
"--response-format is the JSON Schema of what run() returns (inline JSON, @file, or - for stdin).",
Args: cobra.NoArgs,
RunE: runFunctionConfigure,
}
Expand Down Expand Up @@ -574,7 +575,7 @@ func runFunctionConfigure(cmd *cobra.Command, args []string) error {
{"domain", FunctionConfigureDomain},
{"run-instructions", FunctionConfigureInstructions},
}
anyChanged := cmd.Flags().Changed("self-healing")
anyChanged := cmd.Flags().Changed("self-healing") || cmd.Flags().Changed("response-format")
for _, f := range stringFlags {
if !cmd.Flags().Changed(f.name) {
continue
Expand All @@ -588,7 +589,7 @@ func runFunctionConfigure(cmd *cobra.Command, args []string) error {
}
}
if !anyChanged {
return errors.New("nothing to configure: pass --name, --description, --domain, --run-instructions, and/or --self-healing")
return errors.New("nothing to configure: pass --name, --description, --domain, --run-instructions, --response-format, and/or --self-healing")
}

client, err := GetClient()
Expand Down
70 changes: 69 additions & 1 deletion internal/cmd/functionsextra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func configureCmd(t *testing.T) *cobra.Command {
FunctionConfigureDescription = ""
FunctionConfigureDomain = ""
FunctionConfigureInstructions = ""
FunctionConfigureResponseFormat = ""
FunctionConfigureSelfHealing = false
})
return cmd
Expand Down Expand Up @@ -160,7 +161,7 @@ func TestFunctionConfigure_SendsMetadataFieldsAlone(t *testing.T) {
if body[tc.field] != tc.value {
t.Errorf("%s = %v, want %q", tc.field, body[tc.field], tc.value)
}
for _, absent := range []string{"instructions", "self_healing", "name", "description", "domain"} {
for _, absent := range []string{"instructions", "self_healing", "name", "description", "domain", "response_format"} {
if absent == tc.field {
continue
}
Expand All @@ -172,6 +173,73 @@ func TestFunctionConfigure_SendsMetadataFieldsAlone(t *testing.T) {
}
}

// --response-format must be sendable alone, as a JSON object on the PATCH body.
func TestFunctionConfigure_SendsResponseFormatAlone(t *testing.T) {
server := setupFunctionTest(t)
server.AddResponse("/functions/"+functionIDTest, 200, functionJSON())

origFormat := outputFormat
outputFormat = "json"
t.Cleanup(func() { outputFormat = origFormat })

schema := `{"type":"object","properties":{"ok":{"type":"boolean"}}}`
cmd := configureCmd(t)
if err := cmd.Flags().Set("response-format", schema); err != nil {
t.Fatalf("setting --response-format: %v", err)
}

testutil.CaptureOutput(func() {
if err := runFunctionConfigure(cmd, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

body := requestBody(t, server.Requests("/functions/" + functionIDTest)[0])
got, ok := body["response_format"].(map[string]any)
if !ok {
t.Fatalf("response_format = %T %v, want object", body["response_format"], body["response_format"])
}
if got["type"] != "object" {
t.Errorf("response_format.type = %v, want object", got["type"])
}
for _, absent := range []string{"instructions", "self_healing", "name", "description", "domain"} {
if _, present := body[absent]; present {
t.Errorf("%s was sent for a call that only passed --response-format", absent)
}
}
}

// Response schemas are arbitrary JSON Schema documents, so a numeric constraint
// may be larger than a float64 can represent exactly. The configure builder
// must preserve that token on the wire rather than round it during decoding.
func TestFunctionConfigure_PreservesLargeResponseSchemaInteger(t *testing.T) {
server := setupFunctionTest(t)
server.AddResponse("/functions/"+functionIDTest, 200, functionJSON())

origFormat := outputFormat
outputFormat = "json"
t.Cleanup(func() { outputFormat = origFormat })

cmd := configureCmd(t)
if err := cmd.Flags().Set(
"response-format",
`{"type":"object","properties":{"id":{"const":9007199254740993}}}`,
); err != nil {
t.Fatalf("setting --response-format: %v", err)
}

testutil.CaptureOutput(func() {
if err := runFunctionConfigure(cmd, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

body := server.Requests("/functions/" + functionIDTest)[0].Body
if !strings.Contains(body, `"const":9007199254740993`) {
t.Errorf("response schema integer was changed on the wire: %s", body)
}
}

// The generated builder sends an optional string only when it is non-empty, so
// `--flag ""` would reach the API as an empty PATCH: 200, nothing changed, and
// the caller told it worked. Refused up front instead.
Expand Down
51 changes: 47 additions & 4 deletions scripts/gen-flags/codegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,21 @@ func GenerateFlagsFile(config *CommandConfig, schemas map[string]*Field) (string
needsFmt := config.IsMultipart
needsStrconv := false
needsFileIO := false
needsJSON := false
needsStrings := false
for _, fc := range config.Fields {
switch fc.Category {
case CategoryFileUpload:
needsFileIO = true
needsFmt = true
case CategoryJSONDocument:
needsFmt = true
// JSON bodies decode the compacted document into a map; multipart
// bodies write it as a form string and do not need encoding/json here.
if !config.IsMultipart {
needsJSON = true
needsStrings = true
}
case CategoryEnumFlag:
// Actual union types (anyOf with enum + string), not simple enums
if fc.Field.IsUnionType {
Expand Down Expand Up @@ -57,6 +65,9 @@ func GenerateFlagsFile(config *CommandConfig, schemas map[string]*Field) (string
if config.IsMultipart {
buf.WriteString("\t\"bytes\"\n")
}
if needsJSON {
buf.WriteString("\t\"encoding/json\"\n")
}
if needsFmt {
buf.WriteString("\t\"fmt\"\n")
}
Expand All @@ -73,6 +84,9 @@ func GenerateFlagsFile(config *CommandConfig, schemas map[string]*Field) (string
if needsStrconv {
buf.WriteString("\t\"strconv\"\n")
}
if needsStrings {
buf.WriteString("\t\"strings\"\n")
}
buf.WriteString("\n")
buf.WriteString("\t\"github.com/spf13/cobra\"\n")
if !config.IsMultipart {
Expand Down Expand Up @@ -269,10 +283,10 @@ func generateBuildFunction(buf *bytes.Buffer, config *CommandConfig, schemas map
generateFlattenedFieldMapping(buf, fc, config, schemas)
case CategoryRepeatedFlag:
generateRepeatedFieldMapping(buf, fc)
case CategoryJSONDocument, CategoryFileUpload:
// Only reachable for a JSON body, where there is no writer to put a
// document or a file part into. Reported rather than half-generated:
// see the CategoryUnsupported branch in GenerateFlagsFile.
case CategoryJSONDocument:
generateJSONDocumentBodyMapping(buf, fc)
case CategoryFileUpload:
// A file part has nowhere to go on an application/json body.
return fmt.Errorf(
"%s: field %q needs a multipart body but %s %s is application/json",
config.Name, fc.Field.Name, config.HTTPMethod, config.EndpointPath)
Expand Down Expand Up @@ -349,6 +363,35 @@ func generateFilePartMapping(buf *bytes.Buffer, fc *FieldConfig) {
buf.WriteString("\n")
}

// generateJSONDocumentBodyMapping puts a JSON document onto an application/json
// body as an object. readJSONDocumentFlag still owns the CLI input shapes
// (inline / @file / stdin); the compacted string is decoded into the map the
// generated client field expects. UseNumber is necessary because JSON Schema
// permits integers larger than float64 can represent exactly.
func generateJSONDocumentBodyMapping(buf *bytes.Buffer, fc *FieldConfig) {
apiFieldName := toCamelCase(fc.Field.JSONName)
if apiFieldName == "" {
apiFieldName = toCamelCase(fc.Field.Name)
}
local := goLocalName(fc.Field.Name)

fmt.Fprintf(buf, "\t// %s (JSON document)\n", fc.Field.Name)
fmt.Fprintf(buf, "\t%s, err := readJSONDocumentFlag(cmd, \"%s\", %s)\n",
local, fc.FlagName, fc.VarName)
buf.WriteString("\tif err != nil {\n")
buf.WriteString("\t\treturn nil, err\n")
buf.WriteString("\t}\n")
fmt.Fprintf(buf, "\tif %s != \"\" {\n", local)
buf.WriteString("\t\tvar document map[string]interface{}\n")
fmt.Fprintf(buf, "\t\tdecoder := json.NewDecoder(strings.NewReader(%s))\n", local)
buf.WriteString("\t\tdecoder.UseNumber()\n")
buf.WriteString("\t\tif err := decoder.Decode(&document); err != nil {\n")
fmt.Fprintf(buf, "\t\t\treturn nil, fmt.Errorf(\"failed to parse --%s: %%w\", err)\n", fc.FlagName)
buf.WriteString("\t\t}\n")
fmt.Fprintf(buf, "\t\tbody.%s = &document\n", apiFieldName)
buf.WriteString("\t}\n\n")
}

// generateJSONDocumentPartMapping defers to readJSONDocumentFlag, which is
// hand-written: a document can arrive inline, from @file or on stdin, and it is
// validated as JSON before it is sent because the API stores the field verbatim.
Expand Down
79 changes: 77 additions & 2 deletions scripts/gen-flags/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ func TestMultipartBodyGeneratesAFileAndItsFields(t *testing.T) {
}
}

// A JSON body has no writer to put a file part or a document into, so this is
// reported rather than half-generated.
// A JSON body has no writer for a file part, so that is reported rather than
// half-generated. JSON documents are fine: they unmarshal into the body object.
func TestJSONBodyRejectsAFilePart(t *testing.T) {
body := strings.Replace(multipartSpec, `"multipart/form-data"`, `"application/json"`, 1)
spec := parse(t, body)
Expand All @@ -139,6 +139,81 @@ func TestJSONBodyRejectsAFilePart(t *testing.T) {
}
}

// response_format on a JSON PATCH body must land as an object, not a form
// string: FunctionConfigure is application/json and the client field is a map.
func TestJSONBodyGeneratesAJSONDocumentField(t *testing.T) {
spec := parse(t, `{
"openapi": "3.0.3",
"paths": {
"/functions/{function_id}": {
"patch": {
"operationId": "function_metadata_update",
"requestBody": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/FunctionMetadataUpdateRequest"}
}
}
}
}
}
},
"components": {
"schemas": {
"FunctionMetadataUpdateRequest": {
"type": "object",
"properties": {
"name": {"type": "string"},
"response_format": {
"type": "object",
"additionalProperties": true,
"description": "JSON Schema of run()'s return value"
}
}
}
}
}
}`)
schemas := buildSchemaMap(spec)

config, err := extractCommandConfig(
"FunctionConfigure",
"/functions/{function_id}",
"PATCH",
spec.Paths["/functions/{function_id}"].Patch,
schemas,
)
if err != nil {
t.Fatalf("extracting config: %v", err)
}
if config.IsMultipart {
t.Fatal("config.IsMultipart = true, want false for application/json")
}

code, errs, err := GenerateFlagsFile(config, schemas)
if err != nil {
t.Fatalf("generating: %v", err)
}
if len(errs) != 0 {
t.Fatalf("unexpected generation errors: %v", errs)
}

for _, want := range []string{
"func BuildFunctionConfigureRequest(cmd *cobra.Command) (*api.FunctionMetadataUpdateRequest, error)",
`readJSONDocumentFlag(cmd, "response-format", FunctionConfigureResponseFormat)`,
"var document map[string]interface{}",
"decoder := json.NewDecoder(strings.NewReader(responseFormat))",
"decoder.UseNumber()",
"body.ResponseFormat = &document",
`"encoding/json"`,
`"strings"`,
} {
if !strings.Contains(code, want) {
t.Errorf("generated code is missing %q\n%s", want, code)
}
}
}

// The six stale entries this replaces pointed at paths the API had renamed, and
// every one of them failed silently by falling out of the loop.
func TestEndpointMapEntryMissingFromSpecIsAnError(t *testing.T) {
Expand Down
Loading