@styx-api/core 0.7.0 → 0.8.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 +430 -356
- package/dist/index.d.cts +1 -34
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +1 -34
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +431 -352
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/backend/argtype/__fixtures__/microsyntax.argtype +0 -2
- package/src/backend/argtype/emit.ts +2 -51
- package/src/backend/boutiques/boutiques.ts +55 -47
- package/src/backend/field-defaults.ts +40 -0
- package/src/backend/find-struct-node.ts +7 -0
- package/src/backend/index.ts +1 -9
- package/src/backend/nipype/emit.ts +11 -3
- package/src/backend/python/arg-builder.ts +2 -27
- package/src/backend/python/emit.ts +18 -6
- package/src/backend/python/outputs-emit.ts +27 -42
- package/src/backend/python/python.ts +46 -22
- package/src/backend/python/validate-emit.ts +4 -1
- package/src/backend/resolve-output-tokens.ts +0 -76
- package/src/backend/typescript/arg-builder.ts +2 -24
- package/src/backend/typescript/emit.ts +7 -4
- package/src/backend/typescript/outputs-emit.ts +15 -39
- package/src/backend/typescript/typemap.ts +9 -1
- package/src/backend/typescript/typescript.ts +41 -18
- package/src/backend/typescript/validate-emit.ts +4 -1
- package/src/backend/union-variants.ts +41 -1
- package/src/frontend/argdump/parser.ts +20 -19
- package/src/frontend/argtype/__fixtures__/afni-3dtstat.argtype +41 -0
- package/src/frontend/argtype/__fixtures__/bet.argtype +0 -2
- package/src/frontend/argtype/__fixtures__/fsl-flirt.argtype +50 -0
- package/src/frontend/argtype/__fixtures__/wb-command-sub.argtype +39 -0
- package/src/frontend/argtype/ast.ts +14 -1
- package/src/frontend/argtype/frontmatter.ts +49 -5
- package/src/frontend/argtype/lexer.ts +20 -0
- package/src/frontend/argtype/lower.ts +115 -75
- package/src/frontend/argtype/parser-frontend.ts +2 -2
- package/src/frontend/argtype/parser.ts +70 -24
- package/src/frontend/boutiques/destruct-template.ts +24 -17
- package/src/frontend/boutiques/parser.ts +27 -8
- package/src/ir/passes/canonicalize.ts +18 -5
- package/src/ir/passes/simplify.ts +17 -2
- package/src/solver/solver.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@styx-api/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/node": "^25.0.3",
|
|
51
|
+
"fast-check": "^4.9.0",
|
|
51
52
|
"rolldown": "1.0.0-rc.1",
|
|
52
53
|
"styxdefs": "^0.2.0",
|
|
53
54
|
"tsdown": "^0.20.1"
|
|
@@ -191,34 +191,6 @@ function syntheticWrap(root: Expr): { child: Expr; outputs?: Output[] } | undefi
|
|
|
191
191
|
return { child, ...(meta?.outputs && { outputs: meta.outputs }) };
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
function hasOutputs(expr: Expr): boolean {
|
|
195
|
-
return walkSome(expr, (n) => (n.meta?.outputs?.length ?? 0) > 0);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function hasMediaTypes(expr: Expr): boolean {
|
|
199
|
-
return walkSome(expr, (n) => n.kind === "path" && (n.attrs.mediaTypes?.length ?? 0) > 0);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function hasPaths(expr: Expr): boolean {
|
|
203
|
-
return walkSome(expr, (n) => n.kind === "path" && (!!n.attrs.mutable || !!n.attrs.resolveParent));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function walkSome(node: Expr, pred: (n: Expr) => boolean): boolean {
|
|
207
|
-
if (pred(node)) return true;
|
|
208
|
-
switch (node.kind) {
|
|
209
|
-
case "sequence":
|
|
210
|
-
return node.attrs.nodes.some((n) => walkSome(n, pred));
|
|
211
|
-
case "alternative":
|
|
212
|
-
return node.attrs.alts.some((n) => walkSome(n, pred));
|
|
213
|
-
case "optional":
|
|
214
|
-
return walkSome(node.attrs.node, pred);
|
|
215
|
-
case "repeat":
|
|
216
|
-
return walkSome(node.attrs.node, pred);
|
|
217
|
-
default:
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
194
|
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
223
195
|
|
|
224
196
|
/** A name rendered where an identifier is expected: bare when it is a valid
|
|
@@ -342,13 +314,7 @@ class ArgtypeEmitter {
|
|
|
342
314
|
if (raw.meta) root.meta = raw.meta;
|
|
343
315
|
}
|
|
344
316
|
|
|
345
|
-
const frontmatter = this.emitFrontmatter(
|
|
346
|
-
app,
|
|
347
|
-
exe,
|
|
348
|
-
hasOutputs(root),
|
|
349
|
-
hasMediaTypes(root),
|
|
350
|
-
hasPaths(root),
|
|
351
|
-
);
|
|
317
|
+
const frontmatter = this.emitFrontmatter(app, exe);
|
|
352
318
|
if (frontmatter) {
|
|
353
319
|
lines.push(frontmatter, "");
|
|
354
320
|
}
|
|
@@ -641,13 +607,7 @@ class ArgtypeEmitter {
|
|
|
641
607
|
return quote(value);
|
|
642
608
|
}
|
|
643
609
|
|
|
644
|
-
private emitFrontmatter(
|
|
645
|
-
app: AppMeta | undefined,
|
|
646
|
-
exe: string | undefined,
|
|
647
|
-
outputs: boolean,
|
|
648
|
-
mediaTypes: boolean,
|
|
649
|
-
paths: boolean,
|
|
650
|
-
): string | undefined {
|
|
610
|
+
private emitFrontmatter(app: AppMeta | undefined, exe: string | undefined): string | undefined {
|
|
651
611
|
const lines: string[] = [];
|
|
652
612
|
|
|
653
613
|
// `exe` is the executable stripped from the command (argv[0]); emit it only
|
|
@@ -690,15 +650,6 @@ class ArgtypeEmitter {
|
|
|
690
650
|
}
|
|
691
651
|
}
|
|
692
652
|
|
|
693
|
-
const extensions: string[] = [];
|
|
694
|
-
if (outputs) extensions.push("outputs");
|
|
695
|
-
if (mediaTypes) extensions.push("mediatypes");
|
|
696
|
-
if (paths) extensions.push("paths");
|
|
697
|
-
if (extensions.length > 0) {
|
|
698
|
-
lines.push("extensions:");
|
|
699
|
-
for (const e of extensions) lines.push(` - ${e}`);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
653
|
if (lines.length === 0) return undefined;
|
|
703
654
|
return ["---", ...lines, "---"].join("\n");
|
|
704
655
|
}
|
|
@@ -404,28 +404,20 @@ class BoutiquesEmitter {
|
|
|
404
404
|
wrapperNode,
|
|
405
405
|
);
|
|
406
406
|
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
fieldType.kind === "bool" ||
|
|
411
|
-
(fieldType.kind === "optional" && this.isBool(fieldType))
|
|
412
|
-
) {
|
|
413
|
-
// Bool flags: the value-key IS the flag
|
|
414
|
-
commandParts.push(valueKeyStr);
|
|
415
|
-
} else {
|
|
416
|
-
// Value flags: flag then value-key as separate args
|
|
417
|
-
commandParts.push(valueKeyStr);
|
|
418
|
-
}
|
|
419
|
-
} else {
|
|
420
|
-
commandParts.push(valueKeyStr);
|
|
421
|
-
}
|
|
407
|
+
// The value-key alone: a peeled flag rides on the input's
|
|
408
|
+
// `command-line-flag`, and Boutiques renders it ahead of the value.
|
|
409
|
+
commandParts.push(valueKeyStr);
|
|
422
410
|
|
|
423
411
|
inputs.push(input);
|
|
424
412
|
}
|
|
425
413
|
|
|
426
414
|
bt["command-line"] = commandParts.join(" ");
|
|
427
415
|
bt.inputs = inputs;
|
|
428
|
-
|
|
416
|
+
// Resolve the output scope from `structNode` (which carries `meta.outputs`
|
|
417
|
+
// and is what the solver keyed the outputs under), not `expr`: for a plain
|
|
418
|
+
// or list subcommand `findStructNode` descends past `expr`, so resolving from
|
|
419
|
+
// `expr` would miss the bucket and silently drop the subcommand's outputs.
|
|
420
|
+
this.emitOutputFiles(bt, structNode, valueKeyByBinding, idScope);
|
|
429
421
|
}
|
|
430
422
|
|
|
431
423
|
// Emit `output-files` entries declared on this struct binding. `optional`
|
|
@@ -579,6 +571,7 @@ class BoutiquesEmitter {
|
|
|
579
571
|
// had to fall back to String.
|
|
580
572
|
private finalizeInput(input: BtInput): void {
|
|
581
573
|
if (input.name === undefined) input.name = input.id;
|
|
574
|
+
this.finalizeSubDescriptors(input);
|
|
582
575
|
|
|
583
576
|
// A String with a bool default and no choices is really a Flag.
|
|
584
577
|
if (
|
|
@@ -624,6 +617,24 @@ class BoutiquesEmitter {
|
|
|
624
617
|
this.mergeDefaultIntoDescription(input);
|
|
625
618
|
}
|
|
626
619
|
|
|
620
|
+
// A sub-descriptor used as an input `type` needs an `id`: it is required by
|
|
621
|
+
// the schema, and for union variants it doubles as the `@type` discriminator.
|
|
622
|
+
// Anonymous IR nodes (a struct the frontend never named) leave it unset, so
|
|
623
|
+
// fall back to the owning input's id, which is already unique in this scope.
|
|
624
|
+
private finalizeSubDescriptors(input: BtInput): void {
|
|
625
|
+
const type = input.type;
|
|
626
|
+
if (Array.isArray(type)) {
|
|
627
|
+
type.forEach((sub, i) => this.ensureSubDescriptorId(sub, `${input.id}_${i + 1}`));
|
|
628
|
+
} else if (typeof type === "object" && type !== null) {
|
|
629
|
+
this.ensureSubDescriptorId(type, input.id);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private ensureSubDescriptorId(sub: BtDescriptor, fallback: string): void {
|
|
634
|
+
if (sub.id === undefined) sub.id = this.sanitizeId(fallback);
|
|
635
|
+
if (sub.name === undefined) sub.name = sub.id;
|
|
636
|
+
}
|
|
637
|
+
|
|
627
638
|
// Peel wrapper layers from an IR node to extract Boutiques input properties.
|
|
628
639
|
// Walks from outermost to innermost, detecting optional/repeat/flag patterns.
|
|
629
640
|
private peelNode(node: Expr, type: BoundType): PeeledInput {
|
|
@@ -652,6 +663,23 @@ class BoutiquesEmitter {
|
|
|
652
663
|
break;
|
|
653
664
|
|
|
654
665
|
case "sequence": {
|
|
666
|
+
// Two shapes both present as `seq(lit, x)` with a struct-typed field,
|
|
667
|
+
// and only the node tells them apart:
|
|
668
|
+
//
|
|
669
|
+
// struct body seq(lit("--cifti-output"), optional(choice))
|
|
670
|
+
// - the literal is the sub-descriptor's own `command-line`, which
|
|
671
|
+
// `buildFromStruct` serializes by walking these same children.
|
|
672
|
+
// Peeling it would emit it twice.
|
|
673
|
+
// flag wrapper seq(lit("-i"), seq(fixed, moving))
|
|
674
|
+
// - the struct body is `nodes[1]`; the literal is outside it and
|
|
675
|
+
// nothing else serializes it. Not peeling it would drop it.
|
|
676
|
+
//
|
|
677
|
+
// `findStructNode` returns the body, so identity separates the cases.
|
|
678
|
+
const structType = this.unwrapType(type);
|
|
679
|
+
if (structType.kind === "struct" && findStructNode(node, this.ctx, structType) === node) {
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
|
|
655
683
|
// Detect flag pattern: seq(lit(flag), inner)
|
|
656
684
|
const nodes = node.attrs.nodes;
|
|
657
685
|
if (nodes.length === 2 && nodes[0]!.kind === "literal") {
|
|
@@ -820,14 +848,9 @@ class BoutiquesEmitter {
|
|
|
820
848
|
};
|
|
821
849
|
}
|
|
822
850
|
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
return { type: this.buildSubCommandUnion(type, node) };
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// Mixed union -> wrap each variant as a SubCommand descriptor
|
|
830
|
-
return { type: this.buildMixedUnionAsSubCommands(type, node) };
|
|
851
|
+
// Any other union (all-struct or mixed) -> one SubCommand descriptor per
|
|
852
|
+
// variant: struct arms serialize recursively, non-struct arms are wrapped.
|
|
853
|
+
return { type: this.buildUnionSubCommands(type, node) };
|
|
831
854
|
}
|
|
832
855
|
|
|
833
856
|
private buildSubCommand(type: Extract<BoundType, { kind: "struct" }>, node: Expr): BtDescriptor {
|
|
@@ -844,29 +867,10 @@ class BoutiquesEmitter {
|
|
|
844
867
|
return bt;
|
|
845
868
|
}
|
|
846
869
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
const alts = node.kind === "alternative" ? node.attrs.alts : [node];
|
|
852
|
-
|
|
853
|
-
return type.variants.map((variant: BoundVariant, i: number) => {
|
|
854
|
-
const altNode = alts[i] ?? node;
|
|
855
|
-
if (variant.type.kind === "struct") {
|
|
856
|
-
const bt = this.buildSubCommand(variant.type, altNode);
|
|
857
|
-
// The variant has its own name; the wrapperNode's name is the
|
|
858
|
-
// parent (mutex group) name and would otherwise leak down.
|
|
859
|
-
if (variant.name) {
|
|
860
|
-
bt.name = variant.name;
|
|
861
|
-
bt.id = this.sanitizeId(variant.name);
|
|
862
|
-
}
|
|
863
|
-
return bt;
|
|
864
|
-
}
|
|
865
|
-
return this.wrapAsDescriptor(variant, altNode);
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
private buildMixedUnionAsSubCommands(
|
|
870
|
+
// One SubCommand descriptor per union variant. Handles both all-struct and
|
|
871
|
+
// mixed unions: struct arms serialize recursively, non-struct arms are wrapped
|
|
872
|
+
// as a trivial single-input descriptor.
|
|
873
|
+
private buildUnionSubCommands(
|
|
870
874
|
type: Extract<BoundType, { kind: "union" }>,
|
|
871
875
|
node: Expr,
|
|
872
876
|
): BtDescriptor[] {
|
|
@@ -1012,6 +1016,10 @@ class BoutiquesEmitter {
|
|
|
1012
1016
|
type: subBt,
|
|
1013
1017
|
list: true,
|
|
1014
1018
|
minListEntries: countMin,
|
|
1019
|
+
// A count that tolerates zero occurrences may be omitted entirely.
|
|
1020
|
+
// Without this, Boutiques defaults `optional` to false and rejects an
|
|
1021
|
+
// invocation that leaves the flag out. Mirrors the bounded path above.
|
|
1022
|
+
...(countMin === 0 && { optional: true }),
|
|
1015
1023
|
};
|
|
1016
1024
|
}
|
|
1017
1025
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Binding, BoundType } from "../bindings/index.js";
|
|
2
|
+
import type { CodegenContext } from "../manifest/index.js";
|
|
3
|
+
import { collectFieldInfo } from "./collect-field-info.js";
|
|
4
|
+
|
|
5
|
+
/** Renders a default value as a target-language literal. */
|
|
6
|
+
export type RenderLiteral = (value: string | number | boolean) => string;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build the field-name -> rendered-default map for a struct root (else empty).
|
|
10
|
+
* Includes only non-optional defaulted fields (optional fields are
|
|
11
|
+
* presence-guarded; their default comes from the factory's kwarg signature).
|
|
12
|
+
*
|
|
13
|
+
* `rootType` defaults to the resolved root type; callers that already have it
|
|
14
|
+
* (the arg-builders) pass it to avoid re-resolving.
|
|
15
|
+
*/
|
|
16
|
+
export function collectDefaults(
|
|
17
|
+
ctx: CodegenContext,
|
|
18
|
+
renderLiteral: RenderLiteral,
|
|
19
|
+
rootType: BoundType | undefined = ctx.resolve(ctx.expr)?.type,
|
|
20
|
+
): Map<string, string> {
|
|
21
|
+
const out = new Map<string, string>();
|
|
22
|
+
if (rootType?.kind !== "struct") return out;
|
|
23
|
+
for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
|
|
24
|
+
if (fi.defaultValue === undefined) continue;
|
|
25
|
+
if (rootType.fields[name]?.kind === "optional") continue;
|
|
26
|
+
out.set(name, renderLiteral(fi.defaultValue));
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The rendered default for a binding iff it is a root-level defaulted field. */
|
|
32
|
+
export function rootFieldDefault(
|
|
33
|
+
binding: Binding | undefined,
|
|
34
|
+
defaults: ReadonlyMap<string, string>,
|
|
35
|
+
): string | undefined {
|
|
36
|
+
if (!binding) return undefined;
|
|
37
|
+
const a = binding.access;
|
|
38
|
+
if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
@@ -24,6 +24,13 @@ export function findStructNode(
|
|
|
24
24
|
ctx: CodegenContext,
|
|
25
25
|
structType: Extract<BoundType, { kind: "struct" }>,
|
|
26
26
|
): Extract<Expr, { kind: "sequence" }> | undefined {
|
|
27
|
+
// An empty struct (no fields) has no field bindings to match, but it is still a
|
|
28
|
+
// real scope: a parameterless output-bearing command like `seq(lit("run"))`
|
|
29
|
+
// with an output-file. Its scope node is simply the sequence carrying it, so a
|
|
30
|
+
// backend can emit the (field-less) command line and its outputs.
|
|
31
|
+
if (node.kind === "sequence" && Object.keys(structType.fields).length === 0) {
|
|
32
|
+
return node;
|
|
33
|
+
}
|
|
27
34
|
switch (node.kind) {
|
|
28
35
|
case "sequence": {
|
|
29
36
|
// Phase 1: Check if any direct child has a binding matching a struct field
|
package/src/backend/index.ts
CHANGED
|
@@ -22,15 +22,7 @@ export { generateNipype, NipypeBackend, nipypeNames } from "./nipype/index.js";
|
|
|
22
22
|
export type { PydraNames } from "./pydra/index.js";
|
|
23
23
|
export { generatePydra, PydraBackend, pydraNames } from "./pydra/index.js";
|
|
24
24
|
export { resolveFieldBinding } from "./resolve-field-binding.js";
|
|
25
|
-
export
|
|
26
|
-
export {
|
|
27
|
-
compactTokens,
|
|
28
|
-
isGated,
|
|
29
|
-
isIterated,
|
|
30
|
-
outputGate,
|
|
31
|
-
planOutput,
|
|
32
|
-
planScope,
|
|
33
|
-
} from "./resolve-output-tokens.js";
|
|
25
|
+
export { outputGate } from "./resolve-output-tokens.js";
|
|
34
26
|
export { buildEmitModel, generatePython, PythonBackend, renderPythonCall } from "./python/index.js";
|
|
35
27
|
export { Scope } from "./scope.js";
|
|
36
28
|
export type { JsonSchema } from "./schema/index.js";
|
|
@@ -116,11 +116,19 @@ export function renderInputTrait(p: TypedParam): string {
|
|
|
116
116
|
]);
|
|
117
117
|
case "enum": {
|
|
118
118
|
const choices = p.choices ?? [];
|
|
119
|
-
// nipype's first Enum arg is the default; put the styx default first
|
|
120
|
-
|
|
119
|
+
// nipype's first Enum arg is the default; put the styx default first, but
|
|
120
|
+
// only when it is actually one of the choices - prepending an out-of-spec
|
|
121
|
+
// default would silently widen the allowed set. (Enum choices are never
|
|
122
|
+
// booleans, so a boolean default can't be one.)
|
|
123
|
+
const defChoice =
|
|
124
|
+
hasDef && def !== undefined && typeof def !== "boolean" && choices.includes(def)
|
|
125
|
+
? def
|
|
126
|
+
: undefined;
|
|
127
|
+
const ordered =
|
|
128
|
+
defChoice !== undefined ? [defChoice, ...choices.filter((c) => c !== defChoice)] : choices;
|
|
121
129
|
return call("traits.Enum", [
|
|
122
130
|
...ordered.map((c) => renderPyLiteral(c)),
|
|
123
|
-
...(
|
|
131
|
+
...(defChoice !== undefined ? ["usedefault=True"] : []),
|
|
124
132
|
...tail,
|
|
125
133
|
]);
|
|
126
134
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Binding, BindingId, BoundType, BoundVariant } from "../../bindings/index.js";
|
|
2
|
-
import {
|
|
2
|
+
import { collectDefaults, rootFieldDefault } from "../field-defaults.js";
|
|
3
3
|
import type { Expr } from "../../ir/index.js";
|
|
4
4
|
import type { CodegenContext } from "../../manifest/index.js";
|
|
5
5
|
import { CodeBuilder } from "../code-builder.js";
|
|
@@ -97,15 +97,6 @@ interface ArgContext {
|
|
|
97
97
|
* carrying a Boutiques default. Restricted to single-segment (root) field access
|
|
98
98
|
* so a nested field can never accidentally pick up a same-named root default.
|
|
99
99
|
*/
|
|
100
|
-
function rootFieldDefault(
|
|
101
|
-
binding: Binding,
|
|
102
|
-
defaults: ReadonlyMap<string, string>,
|
|
103
|
-
): string | undefined {
|
|
104
|
-
const a = binding.access;
|
|
105
|
-
if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
|
|
106
|
-
return undefined;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
100
|
/**
|
|
110
101
|
* Render a binding's access for an UNCONDITIONAL value read (terminal, repeat
|
|
111
102
|
* loop, alternative dispatch): substitutes the field's default via
|
|
@@ -145,22 +136,6 @@ function accessOf(binding: Binding, arg: ArgContext, opts: AccessOpts = {}): str
|
|
|
145
136
|
);
|
|
146
137
|
}
|
|
147
138
|
|
|
148
|
-
/**
|
|
149
|
-
* Build the field-name -> rendered-default map for a struct root (else empty).
|
|
150
|
-
* Includes only non-optional defaulted fields (optional fields are
|
|
151
|
-
* presence-guarded; their default comes from the factory's kwarg signature).
|
|
152
|
-
*/
|
|
153
|
-
function collectDefaults(ctx: CodegenContext, rootType?: BoundType): Map<string, string> {
|
|
154
|
-
const out = new Map<string, string>();
|
|
155
|
-
if (rootType?.kind !== "struct") return out;
|
|
156
|
-
for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
|
|
157
|
-
if (fi.defaultValue === undefined) continue;
|
|
158
|
-
if (rootType.fields[name]?.kind === "optional") continue;
|
|
159
|
-
out.set(name, renderPyLiteral(fi.defaultValue));
|
|
160
|
-
}
|
|
161
|
-
return out;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
139
|
// -- Recursive descent --
|
|
165
140
|
|
|
166
141
|
let loopVarCounter = 0;
|
|
@@ -179,7 +154,7 @@ export function buildArgs(rootExpr: Expr, ctx: CodegenContext, rootType?: BoundT
|
|
|
179
154
|
joinDepth: 0,
|
|
180
155
|
loopVars: new Map(),
|
|
181
156
|
valueSubst: new Map(),
|
|
182
|
-
defaults: collectDefaults(ctx, rootType),
|
|
157
|
+
defaults: collectDefaults(ctx, renderPyLiteral, rootType),
|
|
183
158
|
};
|
|
184
159
|
return walk(rootExpr, ctx, initialCtx);
|
|
185
160
|
}
|
|
@@ -17,8 +17,13 @@ import { collectFieldInfo, resolveTypeName } from "./types.js";
|
|
|
17
17
|
*/
|
|
18
18
|
export function emitDocstring(cb: CodeBuilder, text?: string): void {
|
|
19
19
|
if (!text) return;
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Escape embedded triple-quotes so a `"""` in the text can't close the
|
|
21
|
+
// docstring early.
|
|
22
|
+
const escaped = text.replace(/"""/g, '\\"\\"\\"');
|
|
23
|
+
const lines = escaped.split("\n");
|
|
24
|
+
// Single-line form only when safe: no embedded quote, and no trailing
|
|
25
|
+
// backslash (which would escape the closing quotes, e.g. `"""x\"""`).
|
|
26
|
+
if (lines.length === 1 && !lines[0]!.includes('"') && !lines[0]!.endsWith("\\")) {
|
|
22
27
|
cb.line(`"""${lines[0]}"""`);
|
|
23
28
|
return;
|
|
24
29
|
}
|
|
@@ -27,13 +32,20 @@ export function emitDocstring(cb: CodeBuilder, text?: string): void {
|
|
|
27
32
|
cb.line(`"""`);
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
export function emitImports(cb: CodeBuilder
|
|
31
|
-
cb.line("import dataclasses");
|
|
35
|
+
export function emitImports(cb: CodeBuilder): void {
|
|
32
36
|
cb.line("import pathlib");
|
|
33
37
|
cb.line("import typing");
|
|
34
38
|
cb.blank();
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
// Every tool emits an Outputs object, so OutputPathType is always needed.
|
|
40
|
+
// (Kept last to preserve the previously emitted import order.)
|
|
41
|
+
const fromStyxdefs = [
|
|
42
|
+
"Execution",
|
|
43
|
+
"InputPathType",
|
|
44
|
+
"Metadata",
|
|
45
|
+
"Runner",
|
|
46
|
+
"StyxValidationError",
|
|
47
|
+
"OutputPathType",
|
|
48
|
+
];
|
|
37
49
|
cb.line(`from styxdefs import ${fromStyxdefs.join(", ")}, get_global_runner`);
|
|
38
50
|
}
|
|
39
51
|
|
|
@@ -1,12 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Binding,
|
|
3
|
-
BindingId,
|
|
4
|
-
BoundType,
|
|
5
|
-
GateAtom,
|
|
6
|
-
ResolvedToken,
|
|
7
|
-
} from "../../bindings/index.js";
|
|
1
|
+
import type { BindingId, BoundType, GateAtom, ResolvedToken } from "../../bindings/index.js";
|
|
8
2
|
import { outputGate } from "../../bindings/index.js";
|
|
9
|
-
import {
|
|
3
|
+
import { collectDefaults, rootFieldDefault } from "../field-defaults.js";
|
|
10
4
|
import type { CodegenContext } from "../../manifest/index.js";
|
|
11
5
|
import { CodeBuilder } from "../code-builder.js";
|
|
12
6
|
import {
|
|
@@ -17,6 +11,7 @@ import {
|
|
|
17
11
|
rootOutput,
|
|
18
12
|
streamFields,
|
|
19
13
|
} from "../collect-output-fields.js";
|
|
14
|
+
import { unionIsMixed, variantAtomUnion } from "../union-variants.js";
|
|
20
15
|
import { PY_KEYWORDS, emitDocstring } from "./emit.js";
|
|
21
16
|
import { pyStr, renderAccess, renderPyLiteral } from "./typemap.js";
|
|
22
17
|
|
|
@@ -40,10 +35,11 @@ export function streamFieldIds(ctx: CodegenContext): { stdout?: string; stderr?:
|
|
|
40
35
|
return res;
|
|
41
36
|
}
|
|
42
37
|
|
|
43
|
-
/** Emit
|
|
38
|
+
/** Emit `class <outputsType>(typing.NamedTuple):` declaration. */
|
|
44
39
|
export function emitOutputsClass(ctx: CodegenContext, outputsType: string, cb: CodeBuilder): void {
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
// A NamedTuple (not a dataclass) matches the styx1 / NiWrap convention: outputs
|
|
41
|
+
// are an immutable, tuple-shaped result built once from keyword args.
|
|
42
|
+
cb.line(`class ${outputsType}(typing.NamedTuple):`);
|
|
47
43
|
cb.indent(() => {
|
|
48
44
|
emitDocstring(cb, "Output paths produced by the tool.");
|
|
49
45
|
const fields = collectOutputFields(ctx, pyId);
|
|
@@ -89,32 +85,6 @@ interface OutputEmitCtx {
|
|
|
89
85
|
defaults: ReadonlyMap<string, string>;
|
|
90
86
|
}
|
|
91
87
|
|
|
92
|
-
/** The rendered default for a binding iff it is a root-level defaulted field. */
|
|
93
|
-
function rootFieldDefault(
|
|
94
|
-
binding: Binding | undefined,
|
|
95
|
-
defaults: ReadonlyMap<string, string>,
|
|
96
|
-
): string | undefined {
|
|
97
|
-
if (!binding) return undefined;
|
|
98
|
-
const a = binding.access;
|
|
99
|
-
if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
|
|
100
|
-
return undefined;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Build the field-name -> rendered-default map for the struct root (else empty).
|
|
104
|
-
* Includes only non-optional defaulted fields (optional fields are
|
|
105
|
-
* presence-guarded; their default comes from the factory's kwarg signature). */
|
|
106
|
-
function collectDefaults(ctx: CodegenContext): Map<string, string> {
|
|
107
|
-
const out = new Map<string, string>();
|
|
108
|
-
const rootType = ctx.resolve(ctx.expr)?.type;
|
|
109
|
-
if (rootType?.kind !== "struct") return out;
|
|
110
|
-
for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
|
|
111
|
-
if (fi.defaultValue === undefined) continue;
|
|
112
|
-
if (rootType.fields[name]?.kind === "optional") continue;
|
|
113
|
-
out.set(name, renderPyLiteral(fi.defaultValue));
|
|
114
|
-
}
|
|
115
|
-
return out;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
88
|
interface WrapperRender {
|
|
119
89
|
open: string;
|
|
120
90
|
loopVar?: string;
|
|
@@ -134,7 +104,17 @@ function renderWrapperOpen(atom: GateAtom, ec: OutputEmitCtx): WrapperRender {
|
|
|
134
104
|
}
|
|
135
105
|
if (atom.kind === "variant") {
|
|
136
106
|
const access = bindingAccess(atom.binding, ec);
|
|
137
|
-
|
|
107
|
+
const check = `${access}["@type"] == ${pyStr(atom.variant)}`;
|
|
108
|
+
// A mixed union (`Literal[0] | Struct`) only carries `@type` on its struct
|
|
109
|
+
// arms; guard on the dict shape first so mypy narrows off the bare-literal
|
|
110
|
+
// arm before the subscript (which would otherwise be a non-indexable type
|
|
111
|
+
// error, and a runtime KeyError on the literal). Mirrors the cargs builder
|
|
112
|
+
// and validator. Pure-struct unions are always indexable - no guard needed.
|
|
113
|
+
const union = variantAtomUnion(atom, ec.ctx.bindings);
|
|
114
|
+
if (union && unionIsMixed(union)) {
|
|
115
|
+
return { open: `if isinstance(${access}, dict) and ${check}:` };
|
|
116
|
+
}
|
|
117
|
+
return { open: `if ${check}:` };
|
|
138
118
|
}
|
|
139
119
|
// present
|
|
140
120
|
const binding = ec.ctx.bindings.get(atom.binding);
|
|
@@ -339,7 +319,7 @@ export function emitBuildOutputs(
|
|
|
339
319
|
ctx,
|
|
340
320
|
iter: new Map(),
|
|
341
321
|
subst: new Map(),
|
|
342
|
-
defaults: collectDefaults(ctx),
|
|
322
|
+
defaults: collectDefaults(ctx, renderPyLiteral),
|
|
343
323
|
};
|
|
344
324
|
|
|
345
325
|
const fields = collectOutputFields(ctx, pyId);
|
|
@@ -393,11 +373,16 @@ export function emitBuildOutputs(
|
|
|
393
373
|
});
|
|
394
374
|
}
|
|
395
375
|
|
|
396
|
-
/**
|
|
376
|
+
/**
|
|
377
|
+
* Sanitize an output name to a valid Python identifier. Uses a *letter*-leading
|
|
378
|
+
* prefix (`v_`) for digit-leading / empty names, never a leading underscore:
|
|
379
|
+
* the Outputs type is a `typing.NamedTuple`, which raises `ValueError` at import
|
|
380
|
+
* time for a field whose name starts with `_`. Matches styx1 and `pyScrubIdent`.
|
|
381
|
+
* (A trailing underscore for keywords is fine - only leading underscores fail.)
|
|
382
|
+
*/
|
|
397
383
|
export function pyId(name: string): string {
|
|
398
384
|
let s = name.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
399
|
-
if (/^\d/.test(s)) s = "
|
|
400
|
-
if (s === "") s = "_";
|
|
385
|
+
if (/^\d/.test(s) || s === "") s = "v_" + s;
|
|
401
386
|
if (PY_KEYWORDS.has(s)) s = s + "_";
|
|
402
387
|
return s;
|
|
403
388
|
}
|