@styx-api/core 0.5.3 → 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/dist/index.cjs +84 -11
- package/dist/index.d.cts +47 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +47 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +84 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/backend/python/python.ts +102 -2
- package/src/backend/python/snippet.ts +54 -6
- package/src/backend/snippet-core.ts +32 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@styx-api/core",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -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
|
|
273
|
-
pySigOptions(
|
|
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
|
|
29
|
-
*
|
|
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, "",
|
|
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 =
|
|
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,
|
|
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
|
-
*
|
|
72
|
-
*
|
|
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
|
|