@styx-api/core 0.4.0 → 0.5.1

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
+ }