@styx-api/core 0.3.0 → 0.5.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 +1027 -273
- package/dist/index.d.cts +226 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +226 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1020 -274
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/backend/index.ts +13 -1
- package/src/backend/nipype/emit.ts +270 -0
- package/src/backend/nipype/index.ts +3 -0
- package/src/backend/nipype/nipype.ts +70 -0
- package/src/backend/pydra/emit.ts +318 -0
- package/src/backend/pydra/index.ts +3 -0
- package/src/backend/pydra/pydra.ts +67 -0
- package/src/backend/python/index.ts +6 -1
- package/src/backend/python/outputs-emit.ts +1 -1
- package/src/backend/python/packaging.ts +33 -5
- package/src/backend/python/python.ts +28 -3
- package/src/backend/schema/jsonschema.ts +34 -4
- package/src/backend/styxdefs-compat.ts +6 -4
- package/src/backend/typed-spec.ts +258 -0
- package/src/frontend/mrtrix/parser.ts +51 -8
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { BoundType } from "../bindings/index.js";
|
|
2
|
+
import type { Expr, ScalarKind } from "../ir/index.js";
|
|
3
|
+
import type { CodegenContext } from "../manifest/index.js";
|
|
4
|
+
import { collectFieldInfo } from "./collect-field-info.js";
|
|
5
|
+
import type { OutputField, StreamField } from "./collect-output-fields.js";
|
|
6
|
+
import { collectOutputFields, streamFields } from "./collect-output-fields.js";
|
|
7
|
+
import { appModuleName, buildEmitModel, pyId } from "./python/index.js";
|
|
8
|
+
import { findNode, findRangeNode, findRepeatNode, structFields } from "./validate-walk.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A flat, rich projection of a tool's solved tree, for Python-ecosystem
|
|
12
|
+
* "declarative spec" backends (nipype, pydra) that DELEGATE EXECUTION to the
|
|
13
|
+
* already-generated styx Python wrapper rather than re-implementing arg-building
|
|
14
|
+
* or output-path templates.
|
|
15
|
+
*
|
|
16
|
+
* The model intentionally reuses the Python backend's naming (`buildEmitModel`'s
|
|
17
|
+
* `sigEntries` and `computePublicNames`-derived handles): both consumers call the
|
|
18
|
+
* Python wrapper by its exact scrubbed kwarg names, so deriving those names from
|
|
19
|
+
* the very function that defines the wrapper signature makes a name mismatch
|
|
20
|
+
* impossible by construction.
|
|
21
|
+
*
|
|
22
|
+
* The hard semantics (argv construction, output filename resolution) stay in the
|
|
23
|
+
* Python backend; this model carries only the *types* the spec frameworks need.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type TypedParamKind =
|
|
27
|
+
| "int"
|
|
28
|
+
| "float"
|
|
29
|
+
| "str"
|
|
30
|
+
| "path"
|
|
31
|
+
| "bool"
|
|
32
|
+
| "count"
|
|
33
|
+
| "enum"
|
|
34
|
+
| "list"
|
|
35
|
+
| "struct"
|
|
36
|
+
| "union";
|
|
37
|
+
|
|
38
|
+
/** Lighter descriptor for a list's element type. */
|
|
39
|
+
export interface TypedParamItem {
|
|
40
|
+
kind: TypedParamKind;
|
|
41
|
+
scalarKind?: ScalarKind;
|
|
42
|
+
choices?: (string | number)[];
|
|
43
|
+
mediaTypes?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One user-facing parameter of the styx kwarg wrapper, with its rich type. */
|
|
47
|
+
export interface TypedParam {
|
|
48
|
+
/** EXACT scrubbed kwarg name the styx Python wrapper exposes. */
|
|
49
|
+
hostName: string;
|
|
50
|
+
/** styx field (wire) name. */
|
|
51
|
+
wireKey: string;
|
|
52
|
+
kind: TypedParamKind;
|
|
53
|
+
scalarKind?: ScalarKind;
|
|
54
|
+
/** True iff the BoundType is `optional` (the key may be absent). */
|
|
55
|
+
optional: boolean;
|
|
56
|
+
/** True iff the field carries an explicit default value (includes flags). */
|
|
57
|
+
hasDefault: boolean;
|
|
58
|
+
/** Required and never defaulted: must always be supplied. */
|
|
59
|
+
mandatory: boolean;
|
|
60
|
+
default?: string | number | boolean;
|
|
61
|
+
doc?: string;
|
|
62
|
+
/** Numeric range for int/float fields. */
|
|
63
|
+
range?: { min?: number; max?: number };
|
|
64
|
+
/** Cardinality bounds for list fields. */
|
|
65
|
+
listBounds?: { min?: number; max?: number };
|
|
66
|
+
/** Allowed values for enum (union-of-literal) fields. */
|
|
67
|
+
choices?: (string | number)[];
|
|
68
|
+
/** Media types declared on a file (path) field. */
|
|
69
|
+
mediaTypes?: string[];
|
|
70
|
+
/** Element descriptor for list fields. */
|
|
71
|
+
itemType?: TypedParamItem;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The delegation surface in the co-generated styx Python module. */
|
|
75
|
+
export interface DelegationTarget {
|
|
76
|
+
/** styx Python module file stem (without extension). */
|
|
77
|
+
moduleName: string;
|
|
78
|
+
/** Kwarg wrapper function name (e.g. `bet`). */
|
|
79
|
+
wrapperFn: string;
|
|
80
|
+
/** Outputs dataclass name (e.g. `BetOutputs`). */
|
|
81
|
+
outputsClass: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Flat, typed projection consumed by the nipype/pydra backends. */
|
|
85
|
+
export interface TypedSpec {
|
|
86
|
+
/**
|
|
87
|
+
* False when the solver collapsed the root to a non-struct (no kwarg surface).
|
|
88
|
+
* Such tools cannot be wrapped by a flat kwarg spec; consumers degrade/skip.
|
|
89
|
+
*/
|
|
90
|
+
rootIsStruct: boolean;
|
|
91
|
+
params: TypedParam[];
|
|
92
|
+
outputs: OutputField[];
|
|
93
|
+
streams: StreamField[];
|
|
94
|
+
delegation: DelegationTarget;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Project a tool's solved tree into the flat typed spec for delegation backends. */
|
|
98
|
+
export function buildTypedSpec(ctx: CodegenContext): TypedSpec {
|
|
99
|
+
const model = buildEmitModel(ctx);
|
|
100
|
+
const delegation: DelegationTarget = {
|
|
101
|
+
moduleName: appModuleName(ctx.app),
|
|
102
|
+
wrapperFn: model.names.wrapper,
|
|
103
|
+
outputsClass: model.names.outputs,
|
|
104
|
+
};
|
|
105
|
+
// Output ids use the Python backend's own sanitizer so they match the
|
|
106
|
+
// dataclass attributes the wrapper returns (consumers read `result.<id>`).
|
|
107
|
+
const outputs = collectOutputFields(ctx, pyId);
|
|
108
|
+
const streams = streamFields(ctx, pyId);
|
|
109
|
+
|
|
110
|
+
if (!model.rootIsStruct || model.rootType.kind !== "struct") {
|
|
111
|
+
return { rootIsStruct: false, params: [], outputs, streams, delegation };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rootType = model.rootType;
|
|
115
|
+
const fieldByName = new Map(structFields(ctx, rootType, ctx.expr).map((f) => [f.name, f]));
|
|
116
|
+
const fieldInfo = collectFieldInfo(ctx, rootType);
|
|
117
|
+
|
|
118
|
+
// Drive from sigEntries: it is the canonical, literal-excluded param list and
|
|
119
|
+
// carries the authoritative scrubbed host names.
|
|
120
|
+
const params = model.sigEntries.map((e) => {
|
|
121
|
+
const fe = fieldByName.get(e.wireKey);
|
|
122
|
+
const fi = fieldInfo.get(e.wireKey);
|
|
123
|
+
return describeParam(e, fe?.type, fe?.node, fi?.defaultValue, fi?.doc);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return { rootIsStruct: true, params, outputs, streams, delegation };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface SigLike {
|
|
130
|
+
name: string;
|
|
131
|
+
wireKey: string;
|
|
132
|
+
isOptional: boolean;
|
|
133
|
+
hasExplicitDefault: boolean;
|
|
134
|
+
doc?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function describeParam(
|
|
138
|
+
e: SigLike,
|
|
139
|
+
type: BoundType | undefined,
|
|
140
|
+
node: Expr | undefined,
|
|
141
|
+
defaultValue: string | number | boolean | undefined,
|
|
142
|
+
fallbackDoc: string | undefined,
|
|
143
|
+
): TypedParam {
|
|
144
|
+
const optional = e.isOptional;
|
|
145
|
+
const hasDefault = e.hasExplicitDefault;
|
|
146
|
+
const base = {
|
|
147
|
+
hostName: e.name,
|
|
148
|
+
wireKey: e.wireKey,
|
|
149
|
+
optional,
|
|
150
|
+
hasDefault,
|
|
151
|
+
mandatory: !optional && !hasDefault,
|
|
152
|
+
default: defaultValue,
|
|
153
|
+
doc: e.doc ?? fallbackDoc,
|
|
154
|
+
};
|
|
155
|
+
const inner = type && type.kind === "optional" ? type.inner : type;
|
|
156
|
+
|
|
157
|
+
const choices = inner ? literalChoices(inner, node) : undefined;
|
|
158
|
+
if (choices) return { ...base, kind: "enum", choices };
|
|
159
|
+
|
|
160
|
+
switch (inner?.kind) {
|
|
161
|
+
case "scalar":
|
|
162
|
+
if (inner.scalar === "path") {
|
|
163
|
+
return { ...base, kind: "path", scalarKind: "path", mediaTypes: pathMediaTypes(node) };
|
|
164
|
+
}
|
|
165
|
+
if (inner.scalar === "int" || inner.scalar === "float") {
|
|
166
|
+
return { ...base, kind: inner.scalar, scalarKind: inner.scalar, range: numericRange(node) };
|
|
167
|
+
}
|
|
168
|
+
return { ...base, kind: "str", scalarKind: "str" };
|
|
169
|
+
case "bool":
|
|
170
|
+
return { ...base, kind: "bool" };
|
|
171
|
+
case "count":
|
|
172
|
+
return { ...base, kind: "count" };
|
|
173
|
+
case "list":
|
|
174
|
+
return {
|
|
175
|
+
...base,
|
|
176
|
+
kind: "list",
|
|
177
|
+
listBounds: listBounds(node),
|
|
178
|
+
itemType: describeItem(inner.item, node),
|
|
179
|
+
};
|
|
180
|
+
case "struct":
|
|
181
|
+
return { ...base, kind: "struct" };
|
|
182
|
+
case "union":
|
|
183
|
+
return { ...base, kind: "union" };
|
|
184
|
+
default:
|
|
185
|
+
// Unresolved / literal / nested-optional: treat as an opaque string.
|
|
186
|
+
return { ...base, kind: "str" };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function describeItem(item: BoundType, node: Expr | undefined): TypedParamItem {
|
|
191
|
+
const inner = item.kind === "optional" ? item.inner : item;
|
|
192
|
+
const choices = literalChoices(inner, node);
|
|
193
|
+
if (choices) return { kind: "enum", choices };
|
|
194
|
+
switch (inner.kind) {
|
|
195
|
+
case "scalar":
|
|
196
|
+
if (inner.scalar === "path") {
|
|
197
|
+
return { kind: "path", scalarKind: "path", mediaTypes: pathMediaTypes(node) };
|
|
198
|
+
}
|
|
199
|
+
return { kind: inner.scalar, scalarKind: inner.scalar };
|
|
200
|
+
case "bool":
|
|
201
|
+
return { kind: "bool" };
|
|
202
|
+
case "count":
|
|
203
|
+
return { kind: "count" };
|
|
204
|
+
case "struct":
|
|
205
|
+
return { kind: "struct" };
|
|
206
|
+
case "union":
|
|
207
|
+
return { kind: "union" };
|
|
208
|
+
default:
|
|
209
|
+
return { kind: "str" };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Allowed values when `inner` is a literal or a union of literals (else undefined). */
|
|
214
|
+
function literalChoices(inner: BoundType, node: Expr | undefined): (string | number)[] | undefined {
|
|
215
|
+
if (inner.kind === "literal") return [inner.value];
|
|
216
|
+
if (
|
|
217
|
+
inner.kind === "union" &&
|
|
218
|
+
inner.variants.length > 0 &&
|
|
219
|
+
inner.variants.every((v) => v.type.kind === "literal")
|
|
220
|
+
) {
|
|
221
|
+
return inner.variants.map((v) => (v.type as Extract<BoundType, { kind: "literal" }>).value);
|
|
222
|
+
}
|
|
223
|
+
// Fallback: an IR alternative of literal nodes the solver did not surface as a
|
|
224
|
+
// union of BoundType literals.
|
|
225
|
+
const altNode = node ? findNode(node, (n) => n.kind === "alternative") : undefined;
|
|
226
|
+
if (altNode && altNode.kind === "alternative") {
|
|
227
|
+
const alts = altNode.attrs.alts;
|
|
228
|
+
if (alts.length > 0 && alts.every((a) => a.kind === "literal")) {
|
|
229
|
+
return alts.map((a) => (a as Extract<Expr, { kind: "literal" }>).attrs.str);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function numericRange(node: Expr | undefined): { min?: number; max?: number } | undefined {
|
|
236
|
+
const r = findRangeNode(node);
|
|
237
|
+
if (!r) return undefined;
|
|
238
|
+
const { minValue, maxValue } = r.attrs;
|
|
239
|
+
if (minValue === undefined && maxValue === undefined) return undefined;
|
|
240
|
+
return { min: minValue, max: maxValue };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function listBounds(node: Expr | undefined): { min?: number; max?: number } | undefined {
|
|
244
|
+
const r = findRepeatNode(node);
|
|
245
|
+
if (!r) return undefined;
|
|
246
|
+
const { countMin, countMax } = r.attrs;
|
|
247
|
+
if (countMin === undefined && countMax === undefined) return undefined;
|
|
248
|
+
return { min: countMin, max: countMax };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function pathMediaTypes(node: Expr | undefined): string[] | undefined {
|
|
252
|
+
const p = node ? findNode(node, (n) => n.kind === "path") : undefined;
|
|
253
|
+
if (p && p.kind === "path") {
|
|
254
|
+
const mt = p.attrs.mediaTypes;
|
|
255
|
+
if (mt && mt.length > 0) return mt;
|
|
256
|
+
}
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
@@ -22,6 +22,10 @@ function isString(x: unknown): x is string {
|
|
|
22
22
|
return typeof x === "string";
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function isNumber(x: unknown): x is number {
|
|
26
|
+
return typeof x === "number" && Number.isFinite(x);
|
|
27
|
+
}
|
|
28
|
+
|
|
25
29
|
function isArray(x: unknown): x is unknown[] {
|
|
26
30
|
return Array.isArray(x);
|
|
27
31
|
}
|
|
@@ -60,9 +64,10 @@ function emptyExpr(): Sequence {
|
|
|
60
64
|
* - option, 1 arg -> opt(seq(lit(-switch), value)) (flat optional)
|
|
61
65
|
* - option, >1 arg / multi -> opt|rep(seq(lit(-switch), ...)) (sub-struct)
|
|
62
66
|
*
|
|
63
|
-
* Type mapping mirrors the v1 `mrt2bt.js` converter's `set_type
|
|
64
|
-
*
|
|
65
|
-
*
|
|
67
|
+
* Type mapping mirrors the v1 `mrt2bt.js` converter's `set_type`, plus `choice`
|
|
68
|
+
* values: when the dump carries `choices` it lowers to an enum (an alternative
|
|
69
|
+
* of literals), else a plain string (older dumps that omit the values - as v1
|
|
70
|
+
* always did). Per-command quirks v1 hand-coded that the flat dump cannot
|
|
66
71
|
* express (e.g. dwi2fod/mtnormalise paired in/out args) are intentionally NOT
|
|
67
72
|
* special-cased here - they are patched on the niwrap side post-dump, keeping
|
|
68
73
|
* this frontend format-general.
|
|
@@ -160,6 +165,20 @@ export class MrtrixParser implements Frontend {
|
|
|
160
165
|
|
|
161
166
|
// -- Terminals --
|
|
162
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Integer/float bounds, when the dump carries them. The C++ hook serializes
|
|
170
|
+
* `Argument::limits.{i,f}.{min,max}` as `min`/`max`, omitting the unbounded
|
|
171
|
+
* sentinels - so a present value is a real, tool-enforced bound.
|
|
172
|
+
*/
|
|
173
|
+
private numericBounds(arg: Record<string, unknown>): { minValue?: number; maxValue?: number } {
|
|
174
|
+
const lo = arg.min;
|
|
175
|
+
const hi = arg.max;
|
|
176
|
+
return {
|
|
177
|
+
...(isNumber(lo) && { minValue: lo }),
|
|
178
|
+
...(isNumber(hi) && { maxValue: hi }),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
163
182
|
/**
|
|
164
183
|
* Lower one MRtrix argument to its terminal node (carrying name + doc) and,
|
|
165
184
|
* for output types, an accompanying `Output`.
|
|
@@ -177,14 +196,38 @@ export class MrtrixParser implements Frontend {
|
|
|
177
196
|
const name = meta.name!;
|
|
178
197
|
|
|
179
198
|
switch (argType) {
|
|
180
|
-
case "integer":
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return { node
|
|
199
|
+
case "integer": {
|
|
200
|
+
const node = int(meta);
|
|
201
|
+
Object.assign(node.attrs, this.numericBounds(arg));
|
|
202
|
+
return { node };
|
|
203
|
+
}
|
|
204
|
+
case "float": {
|
|
205
|
+
const node = float(meta);
|
|
206
|
+
Object.assign(node.attrs, this.numericBounds(arg));
|
|
207
|
+
return { node };
|
|
208
|
+
}
|
|
184
209
|
case "text":
|
|
185
|
-
case "choice": // choice values are not emitted in the dump; treat as string
|
|
186
210
|
case "undefined":
|
|
211
|
+
case "boolean":
|
|
212
|
+
// `boolean` rarely (if ever) appears in a dump; treat its literal token
|
|
213
|
+
// as a string rather than erroring on an unknown type.
|
|
187
214
|
return { node: str(meta) };
|
|
215
|
+
case "choice": {
|
|
216
|
+
// A `choice` carries its allowed values in `choices` (the C++ hook
|
|
217
|
+
// serializes `Argument::limits.choices`). Lower to an enum: an
|
|
218
|
+
// alternative of string literals. Older dumps omit the values - fall
|
|
219
|
+
// back to a plain string so they still compile.
|
|
220
|
+
const choices = arg.choices;
|
|
221
|
+
if (isArray(choices)) {
|
|
222
|
+
const alts = choices.filter(isString).map((c) => lit(c));
|
|
223
|
+
if (alts.length > 0) {
|
|
224
|
+
const node = alt(...alts);
|
|
225
|
+
node.meta = meta;
|
|
226
|
+
return { node };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { node: str(meta) };
|
|
230
|
+
}
|
|
188
231
|
// MRtrix sequences are always a single comma-separated token (parse_ints /
|
|
189
232
|
// parse_floats split on `,`), so the list is comma-joined, never spaced.
|
|
190
233
|
case "int seq": {
|