@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.
@@ -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`. The dump does
64
- * not carry choice values, so a `choice` argument degrades to a plain string
65
- * (as it did in v1). Per-command quirks v1 hand-coded that the flat dump cannot
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
- return { node: int(meta) };
182
- case "float":
183
- return { node: float(meta) };
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": {