@styx-api/core 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@styx-api/core",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Styx compiler core: parses CLI tool descriptors (e.g. Boutiques), optimizes an IR, solves typed parameter bindings, and generates type-safe wrappers (TypeScript, Python, JSON Schema). Part of the Styx/NiWrap ecosystem.",
5
5
  "keywords": [
6
6
  "styx",
@@ -50,8 +50,14 @@ export function emitMetadata(ctx: CodegenContext, metaConst: string, cb: CodeBui
50
50
  if (ctx.app?.doc?.literature?.length) {
51
51
  cb.line(`citations=[${ctx.app.doc.literature.map(pyStr).join(", ")}],`);
52
52
  }
53
- if (ctx.app?.container?.image) {
54
- cb.line(`container_image_tag=${pyStr(ctx.app.container.image)},`);
53
+ // The package/version-level container is authoritative: niwrap curates one
54
+ // container per tool version (version.json) and applies it to every app, so
55
+ // it overrides a descriptor's incidental inline container-image. The inline
56
+ // image is only a fallback for a standalone single-descriptor build that has
57
+ // no package context.
58
+ const containerImage = ctx.package?.docker ?? ctx.app?.container?.image;
59
+ if (containerImage) {
60
+ cb.line(`container_image_tag=${pyStr(containerImage)},`);
55
61
  }
56
62
  });
57
63
  cb.line(")");
@@ -165,6 +165,28 @@ export function computePublicNames(appId: string | undefined): PublicNames {
165
165
  };
166
166
  }
167
167
 
168
+ /**
169
+ * A kwarg `_params` builder for one nested (non-root) struct - a union variant
170
+ * or a plain nested sub-struct. Lets callers write
171
+ * `output=tool_corrected_output_params(...)` instead of hand-authoring a
172
+ * `{"@type": ...}` dict. The root struct's factory is `names.paramsFn`; this
173
+ * covers everything below it.
174
+ */
175
+ export interface NestedFactory {
176
+ /** The struct's TypedDict name (factory return + `params` annotation type). */
177
+ typeName: string;
178
+ /** Structural identity key of the struct - used by the snippet renderer to
179
+ * route a nested struct value to its factory. */
180
+ structKey: string;
181
+ /** Registered factory function name (e.g. `tool_corrected_output_params`). */
182
+ funcName: string;
183
+ /** The struct's `@type` discriminator, injected by the factory; undefined for
184
+ * a plain sub-struct that carries no discriminator. */
185
+ typeTag: string | undefined;
186
+ /** Per-field signature entries (shared with the snippet renderer). */
187
+ sigEntries: SigEntry[];
188
+ }
189
+
168
190
  /**
169
191
  * The fully-derived naming/typing model for one tool's Python emission. Computed
170
192
  * once by `buildEmitModel` so the file emitter and the call-site snippet renderer
@@ -192,6 +214,7 @@ export interface PyEmitModel {
192
214
  rootTypeTag: string | undefined;
193
215
  paramsType: string;
194
216
  sigEntries: SigEntry[];
217
+ nestedFactories: NestedFactory[];
195
218
  }
196
219
 
197
220
  /**
@@ -257,23 +280,88 @@ export function buildEmitModel(
257
280
  ? names.params
258
281
  : mapType(rootType, resolveTypeName(namedTypes));
259
282
 
283
+ // Host kwarg names are snake_cased so a tool's signature reads idiomatically
284
+ // (`corrected_output_file_name=`) regardless of how the descriptor authored
285
+ // the wire id (camelCase Boutiques sub-ids, etc.); the dict key keeps the
286
+ // original wire name, so this divergence is the same one as `float` ->
287
+ // `float_`. Scrubbed for validity / reserved words, then deduped through the
288
+ // function scope. Shared by the root wrapper and every nested factory so they
289
+ // stay in lockstep.
290
+ const hostName = (childScope: Scope, wireKey: string): string =>
291
+ childScope.add(pyScrubIdent(snakeCase(wireKey), PY_RESERVED));
292
+
260
293
  // Build the per-field SigEntry list once - the factory and kwarg wrapper
261
294
  // both consume it, so the host names registered here must satisfy both
262
295
  // function scopes. Pre-reserve `params` (factory + wrapper body) and
263
296
  // `runner` (wrapper signature) so a wire key matching either gets
264
297
  // suffix-bumped. `rootType` is narrowed by `rootIsStruct` for the `Extract`
265
298
  // constraint.
299
+ const resolve = resolveTypeName(namedTypes);
266
300
  const sigScope = scope.child(["params", "runner"]);
267
301
  const sigEntries =
268
302
  rootIsStruct && rootType.kind === "struct"
269
303
  ? buildSigEntries(
270
304
  rootType,
271
305
  collectFieldInfo(ctx, rootType),
272
- (wireKey) => sigScope.add(pyScrubIdent(wireKey, PY_RESERVED)),
273
- pySigOptions(resolveTypeName(namedTypes)),
306
+ (wireKey) => hostName(sigScope, wireKey),
307
+ pySigOptions(resolve),
274
308
  )
275
309
  : [];
276
310
 
311
+ // Nested-struct factories: every non-root struct (union variants and plain
312
+ // nested sub-structs, all already collected into `typeDecls`) gets a kwarg
313
+ // `_params` builder so callers don't have to hand-author `{"@type": ...}`
314
+ // dicts. Unions are bare type aliases (the caller picks a variant), so only
315
+ // struct decls get a factory. The factory name is derived from the
316
+ // (already tool-prefixed, unique) TypedDict name and registered in the shared
317
+ // scope so it stays unique across the suite's flat `from .x import *`.
318
+ //
319
+ // Why this is Python-only (the TypeScript backend keeps nested objects as
320
+ // inline literals): a nested object literal is idiomatic, well-typed API
321
+ // design in TypeScript, but in Python it reads as an untyped dict blob.
322
+ // niwrap's Python audience has used the factory-builder pattern since v1
323
+ // (`tool_sub_params(...)`), so emitting these factories restores the
324
+ // convention downstream users expect rather than forcing raw tagged dicts.
325
+ // The two backends deliberately diverge here; see `structConstructor` in
326
+ // snippet-core for the matching seam on the snippet side.
327
+ const rootStructKey =
328
+ rootIsStruct && rootType.kind === "struct" ? structKey(rootType) : undefined;
329
+ const nestedFactories: NestedFactory[] = [];
330
+ for (const decl of typeDecls) {
331
+ if (decl.type.kind !== "struct") continue;
332
+ const sKey = structKey(decl.type);
333
+ if (sKey === rootStructKey) continue; // root already has its `_params` factory
334
+ const funcName = scope.add(pyScrubIdent(snakeCase(decl.name) + "_params", PY_RESERVED));
335
+ // Union variants carry their discriminator as a required `@type` literal
336
+ // field; the factory injects it. A plain nested sub-struct has none.
337
+ //
338
+ // The `string` narrowing is total in practice, not a silent drop: the
339
+ // solver always builds `@type` from a variant's string `name`, so the
340
+ // literal value is invariably a string. (If a numeric `@type` ever slipped
341
+ // through, `emitStructTypedDict` would still emit it as a required
342
+ // `Literal[<n>]` field, so the un-injected dict would fail the mypy --strict
343
+ // codegen gate rather than miscompile silently.)
344
+ const atType = decl.type.fields["@type"];
345
+ const typeTag =
346
+ atType && atType.kind === "literal" && typeof atType.value === "string"
347
+ ? atType.value
348
+ : undefined;
349
+ const factoryScope = scope.child(["params"]);
350
+ const factorySig = buildSigEntries(
351
+ decl.type,
352
+ collectFieldInfo(ctx, decl.type),
353
+ (wireKey) => hostName(factoryScope, wireKey),
354
+ pySigOptions(resolve),
355
+ );
356
+ nestedFactories.push({
357
+ typeName: decl.name,
358
+ structKey: sKey,
359
+ funcName,
360
+ typeTag,
361
+ sigEntries: factorySig,
362
+ });
363
+ }
364
+
277
365
  return {
278
366
  appId,
279
367
  pkg,
@@ -285,6 +373,7 @@ export function buildEmitModel(
285
373
  rootTypeTag,
286
374
  paramsType,
287
375
  sigEntries,
376
+ nestedFactories,
288
377
  };
289
378
  }
290
379
 
@@ -303,6 +392,7 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
303
392
  rootTypeTag,
304
393
  paramsType,
305
394
  sigEntries,
395
+ nestedFactories,
306
396
  } = buildEmitModel(ctx, scope);
307
397
 
308
398
  // Auto-generated header.
@@ -341,6 +431,15 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
341
431
  cb.blank();
342
432
  }
343
433
 
434
+ // Nested-struct factories: one kwarg `_params` builder per union variant /
435
+ // nested sub-struct, so callers can write `output=tool_x_params(...)` instead
436
+ // of a hand-authored `{"@type": ...}` dict. Their TypedDicts are already
437
+ // declared above (emitTypeDeclarations), so the eager annotations resolve.
438
+ for (const nf of nestedFactories) {
439
+ emitParamsFactory(nf.sigEntries, nf.funcName, nf.typeName, nf.typeTag, cb);
440
+ cb.blank();
441
+ }
442
+
344
443
  // Validation: walks the root binding and raises StyxValidationError on bad
345
444
  // input. Called first thing in the dict-style execute (below).
346
445
  emitValidate(
@@ -403,6 +502,7 @@ export function generatePython(ctx: CodegenContext, packageScope?: Scope): strin
403
502
  names.cargs,
404
503
  ...(emitOutputs ? [names.outputsFn] : []),
405
504
  ...(rootIsStruct ? [names.paramsFn, names.execute] : []),
505
+ ...nestedFactories.map((nf) => nf.funcName),
406
506
  names.validate,
407
507
  names.wrapper,
408
508
  ];
@@ -3,6 +3,8 @@ import type { CodegenContext } from "../../manifest/index.js";
3
3
  import type { SigEntry } from "../sig-entries.js";
4
4
  import type { SnippetDialect, SnippetOptions } from "../snippet-core.js";
5
5
  import { renderValue } from "../snippet-core.js";
6
+ import { structKey } from "../type-keys.js";
7
+ import type { PyEmitModel } from "./python.js";
6
8
  import { buildEmitModel } from "./python.js";
7
9
  import { pyStr } from "./typemap.js";
8
10
 
@@ -16,6 +18,49 @@ const pyDialect: SnippetDialect = {
16
18
  objKey: (k) => pyStr(k),
17
19
  };
18
20
 
21
+ /**
22
+ * Build a Python dialect that renders nested structs as `_params` factory calls
23
+ * (matching the generated code's per-struct builders) rather than plain dicts.
24
+ * Each factory is looked up by the struct's structural key; field values use the
25
+ * factory's scrubbed host kwarg names (from the same `buildSigEntries` the
26
+ * generated factory is built from), and the function name is package-qualified
27
+ * so the call resolves through `from <root> import <pkg>`. A struct with no
28
+ * registered factory (shouldn't happen for well-formed nested types) falls back
29
+ * to the plain dict literal.
30
+ */
31
+ function pyDialectWithFactories(model: PyEmitModel, pkg: string | undefined): SnippetDialect {
32
+ // Keyed only by the NESTED factories - the root struct is intentionally
33
+ // absent (it has no entry in `model.nestedFactories`), so a struct that is
34
+ // structurally identical to the root falls through to the plain dict literal
35
+ // rather than a factory call. That case is rare (requires no `@type`
36
+ // injection) and harmless; this is not a missed registration.
37
+ const byKey = new Map<string, { call: string; nameByWire: Map<string, string> }>();
38
+ for (const f of model.nestedFactories) {
39
+ byKey.set(f.structKey, {
40
+ call: pkg ? `${pkg}.${f.funcName}` : f.funcName,
41
+ nameByWire: new Map(f.sigEntries.map((e) => [e.wireKey, e.name])),
42
+ });
43
+ }
44
+ return {
45
+ ...pyDialect,
46
+ structConstructor(type, value, indent, d) {
47
+ const entry = byKey.get(structKey(type));
48
+ if (!entry) return undefined;
49
+ const inner = indent + d.indentUnit;
50
+ const lines: string[] = [];
51
+ for (const [wireKey, fieldType] of Object.entries(type.fields)) {
52
+ if (wireKey === "@type") continue; // injected by the factory
53
+ if (fieldType.kind === "literal") continue; // no runtime representation
54
+ if (!(wireKey in value)) continue; // omitted optional / absent field
55
+ const name = entry.nameByWire.get(wireKey) ?? wireKey;
56
+ lines.push(`${inner}${name}=${renderValue(value[wireKey], fieldType, inner, d)},`);
57
+ }
58
+ if (lines.length === 0) return `${entry.call}()`;
59
+ return `${entry.call}(\n${lines.join("\n")}\n${indent})`;
60
+ },
61
+ };
62
+ }
63
+
19
64
  /**
20
65
  * Render a Python call snippet for one tool from a config object (the params
21
66
  * dict the form produces, keyed by Boutiques wire names).
@@ -25,8 +70,9 @@ const pyDialect: SnippetDialect = {
25
70
  * *scrubbed host* identifiers (`float` -> `float_`), not the wire keys; the
26
71
  * per-field mapping comes from the same `buildSigEntries` the generated wrapper
27
72
  * is built from, so the snippet matches the real signature. Nested structs /
28
- * union variants / lists-of-structs have no constructor in the generated code,
29
- * so they render as plain dict literals keyed by wire names.
73
+ * union variants / lists-of-structs render as calls to their generated
74
+ * `_params` factory (`fsl.bet_x_params(...)`), matching the per-struct builders
75
+ * the Python backend emits.
30
76
  *
31
77
  * Union- (or otherwise non-struct-) rooted tools have no kwarg wrapper; the
32
78
  * single dict-style `<tool>` entry is called with one object-literal argument.
@@ -51,11 +97,12 @@ export function renderPythonCall(
51
97
  const model = buildEmitModel(ctx);
52
98
  const pkg = ctx.package?.name;
53
99
  const callee = pkg ? `${pkg}.${model.names.wrapper}` : model.names.wrapper;
100
+ const dialect = pyDialectWithFactories(model, pkg);
54
101
 
55
102
  const call =
56
103
  model.rootIsStruct && model.rootType.kind === "struct"
57
- ? renderKwargCall(callee, model.sigEntries, model.rootType, config)
58
- : `${callee}(${renderValue(config, model.rootType, "", pyDialect)})`;
104
+ ? renderKwargCall(callee, model.sigEntries, model.rootType, config, dialect)
105
+ : `${callee}(${renderValue(config, model.rootType, "", dialect)})`;
59
106
 
60
107
  if (opts.includeImport === false || !pkg) return call;
61
108
  const root = opts.packageRoot ?? ctx.project?.name;
@@ -69,15 +116,16 @@ function renderKwargCall(
69
116
  sigEntries: readonly SigEntry[],
70
117
  rootType: Extract<BoundType, { kind: "struct" }>,
71
118
  config: Record<string, unknown>,
119
+ dialect: SnippetDialect,
72
120
  ): string {
73
121
  const nameFor = new Map(sigEntries.map((e) => [e.wireKey, e.name]));
74
- const indent = pyDialect.indentUnit;
122
+ const indent = dialect.indentUnit;
75
123
  const lines: string[] = [];
76
124
  for (const [wireKey, fieldType] of Object.entries(rootType.fields)) {
77
125
  if (fieldType.kind === "literal") continue; // @type / consts injected by the wrapper
78
126
  if (!(wireKey in config)) continue;
79
127
  const name = nameFor.get(wireKey) ?? wireKey;
80
- lines.push(`${indent}${name}=${renderValue(config[wireKey], fieldType, indent, pyDialect)},`);
128
+ lines.push(`${indent}${name}=${renderValue(config[wireKey], fieldType, indent, dialect)},`);
81
129
  }
82
130
  if (lines.length === 0) return `${callee}()`;
83
131
  return `${callee}(\n${lines.join("\n")}\n)`;
@@ -19,6 +19,24 @@ export interface SnippetDialect {
19
19
  null: string;
20
20
  /** Render an object-literal key from a wire key, quoting when not a bare identifier. */
21
21
  objKey(wireKey: string): string;
22
+ /**
23
+ * Optional: render a nested struct as a constructor/factory call instead of a
24
+ * plain object literal. A backend that generates per-struct builders (Python's
25
+ * `tool_x_params(...)`) supplies this; one that doesn't (TypeScript) leaves it
26
+ * unset and gets the object-literal form. Return `undefined` to fall back to
27
+ * the object literal for this particular struct (e.g. no matching factory).
28
+ *
29
+ * The Python/TypeScript divergence is deliberate: nested object literals are
30
+ * idiomatic in TypeScript, whereas Python's niwrap audience expects the
31
+ * factory-builder pattern (`tool_sub_params(...)`) it has used since v1. See
32
+ * the nested-factory block in `python.ts` for the full rationale.
33
+ */
34
+ structConstructor?(
35
+ type: Extract<BoundType, { kind: "struct" }>,
36
+ value: Record<string, unknown>,
37
+ indent: string,
38
+ d: SnippetDialect,
39
+ ): string | undefined;
22
40
  }
23
41
 
24
42
  /** Options shared by both language renderers. */
@@ -67,9 +85,13 @@ function structAtType(type: Extract<BoundType, { kind: "struct" }>): string | un
67
85
 
68
86
  /**
69
87
  * Render a struct config as a host object literal (Python dict / TS object).
70
- * Keys are the Boutiques wire names (the generated TypedDict / interface keys) -
71
- * nested structs have no constructor function in the generated code, so callers
72
- * build them as plain object literals.
88
+ * Keys are the Boutiques wire names (the generated TypedDict / interface keys).
89
+ *
90
+ * When the dialect supplies `structConstructor` (Python, which generates a
91
+ * per-struct `_params` builder), the struct is rendered as that factory call
92
+ * instead - e.g. `tool_corrected_output_params(file="...")`. Backends without
93
+ * per-struct builders (TypeScript) leave the hook unset and build the plain
94
+ * object literal below.
73
95
  *
74
96
  * `@type` is emitted from the struct's literal discriminator field when present
75
97
  * (union variants carry a required, load-bearing `@type`); for the root call the
@@ -85,6 +107,13 @@ export function renderStructLiteral(
85
107
  injectAtType?: string,
86
108
  ): string {
87
109
  const obj = isRecord(value) ? value : {};
110
+ // A factory call carries its own `@type`, so it's only valid when the struct's
111
+ // discriminator comes from the struct itself (or none) - not when the tag must
112
+ // be injected from outside (the root call, which never reaches this path).
113
+ if (injectAtType === undefined && d.structConstructor) {
114
+ const call = d.structConstructor(type, obj, indent, d);
115
+ if (call !== undefined) return call;
116
+ }
88
117
  const inner = indent + d.indentUnit;
89
118
  const lines: string[] = [];
90
119
 
@@ -42,8 +42,14 @@ export function emitMetadata(ctx: CodegenContext, metaConst: string, cb: CodeBui
42
42
  if (ctx.app?.doc?.literature?.length) {
43
43
  cb.line(`citations: ${JSON.stringify(ctx.app.doc.literature)},`);
44
44
  }
45
- if (ctx.app?.container?.image) {
46
- cb.line(`container_image_tag: ${JSON.stringify(ctx.app.container.image)},`);
45
+ // The package/version-level container is authoritative: niwrap curates one
46
+ // container per tool version (version.json) and applies it to every app, so
47
+ // it overrides a descriptor's incidental inline container-image. The inline
48
+ // image is only a fallback for a standalone single-descriptor build that has
49
+ // no package context.
50
+ const containerImage = ctx.package?.docker ?? ctx.app?.container?.image;
51
+ if (containerImage) {
52
+ cb.line(`container_image_tag: ${JSON.stringify(containerImage)},`);
47
53
  }
48
54
  });
49
55
  cb.line("};");