@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@styx-api/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.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",
@@ -16,6 +16,10 @@ export type { NamedType } from "./collect-named-types.js";
16
16
  export { collectNamedTypes, resolveTypeName } from "./collect-named-types.js";
17
17
  export { findDoc } from "./find-doc.js";
18
18
  export { findStructNode } from "./find-struct-node.js";
19
+ export type { NipypeNames } from "./nipype/index.js";
20
+ export { generateNipype, NipypeBackend, nipypeNames } from "./nipype/index.js";
21
+ export type { PydraNames } from "./pydra/index.js";
22
+ export { generatePydra, PydraBackend, pydraNames } from "./pydra/index.js";
19
23
  export { resolveFieldBinding } from "./resolve-field-binding.js";
20
24
  export type { OutputEmitPlan } from "./resolve-output-tokens.js";
21
25
  export {
@@ -26,7 +30,7 @@ export {
26
30
  planOutput,
27
31
  planScope,
28
32
  } from "./resolve-output-tokens.js";
29
- export { generatePython, PythonBackend, renderPythonCall } from "./python/index.js";
33
+ export { buildEmitModel, generatePython, PythonBackend, renderPythonCall } from "./python/index.js";
30
34
  export { Scope } from "./scope.js";
31
35
  export type { JsonSchema } from "./schema/index.js";
32
36
  export { generateOutputsSchema, generateSchema, JsonSchemaBackend } from "./schema/index.js";
@@ -36,6 +40,14 @@ export { renderStructLiteral, renderValue } from "./snippet-core.js";
36
40
  export type { SnippetDialect, SnippetOptions } from "./snippet-core.js";
37
41
  export { camelCase, pascalCase, screamingSnakeCase, snakeCase } from "./string-case.js";
38
42
  export { PYTHON_RUNNER_DEPS, STYXDEFS_COMPAT } from "./styxdefs-compat.js";
43
+ export type {
44
+ DelegationTarget,
45
+ TypedParam,
46
+ TypedParamItem,
47
+ TypedParamKind,
48
+ TypedSpec,
49
+ } from "./typed-spec.js";
50
+ export { buildTypedSpec } from "./typed-spec.js";
39
51
  export { structKey, typeKey, unionKey } from "./type-keys.js";
40
52
  export {
41
53
  appEntrypoint,
@@ -0,0 +1,270 @@
1
+ import type { CodegenContext } from "../../manifest/index.js";
2
+ import { CodeBuilder } from "../code-builder.js";
3
+ import type { OutputField } from "../collect-output-fields.js";
4
+ import { emitDocstring } from "../python/emit.js";
5
+ import { pyStr, renderPyLiteral } from "../python/typemap.js";
6
+ import type { TypedParam, TypedParamItem, TypedSpec } from "../typed-spec.js";
7
+
8
+ /** Generated symbol names for one tool's nipype interface module. */
9
+ export interface NipypeNames {
10
+ /** Styx Python module file stem to import the wrapper from (e.g. `_bet`). */
11
+ styxStem: string;
12
+ /** Interface module file stem (e.g. `bet`). */
13
+ ifaceStem: string;
14
+ /** Interface class name (e.g. `Bet`). */
15
+ cls: string;
16
+ /** Input spec class name (e.g. `BetInputSpec`). */
17
+ inputSpec: string;
18
+ /** Output spec class name (e.g. `BetOutputSpec`). */
19
+ outputSpec: string;
20
+ }
21
+
22
+ function call(ctor: string, args: string[]): string {
23
+ return `${ctor}(${args.join(", ")})`;
24
+ }
25
+
26
+ /**
27
+ * Render a numeric literal, forcing a float form (e.g. `0.0`) for a float field.
28
+ * `traits.Range` infers its numeric type from the bound literals, so integer
29
+ * bounds on a float field (e.g. `low=0, high=1`) would build an *integer* range
30
+ * that rejects `0.5`. Emitting `0.0`/`1.0` keeps it a float range.
31
+ */
32
+ function renderNum(value: string | number | boolean, asFloat: boolean): string {
33
+ if (asFloat && typeof value === "number" && Number.isInteger(value)) return `${value}.0`;
34
+ return renderPyLiteral(value);
35
+ }
36
+
37
+ /** Human-readable `desc=` text: doc + degrade/media-type notes. */
38
+ function descText(p: TypedParam): string | undefined {
39
+ const parts: string[] = [];
40
+ if (p.doc) parts.push(p.doc);
41
+ if (p.kind === "struct" || p.kind === "union") parts.push("(nested configuration; pass a dict)");
42
+ if (p.mediaTypes && p.mediaTypes.length > 0) {
43
+ parts.push(`(media types: ${p.mediaTypes.join(", ")})`);
44
+ }
45
+ return parts.length > 0 ? parts.join(" ") : undefined;
46
+ }
47
+
48
+ /** Map a list element descriptor to a (bare) nipype inner trait. */
49
+ function renderItemTrait(item: TypedParamItem | undefined): string {
50
+ if (!item) return "traits.Any()";
51
+ switch (item.kind) {
52
+ case "path":
53
+ return "File(exists=True)";
54
+ case "int":
55
+ case "count":
56
+ return "traits.Int()";
57
+ case "float":
58
+ return "traits.Float()";
59
+ case "str":
60
+ return "traits.Str()";
61
+ case "bool":
62
+ return "traits.Bool()";
63
+ case "enum":
64
+ return call(
65
+ "traits.Enum",
66
+ (item.choices ?? []).map((c) => renderPyLiteral(c)),
67
+ );
68
+ default:
69
+ return "traits.Any()";
70
+ }
71
+ }
72
+
73
+ /** Map a parameter to its nipype input trait expression, carrying rich constraints. */
74
+ export function renderInputTrait(p: TypedParam): string {
75
+ const tail: string[] = [];
76
+ if (p.mandatory) tail.push("mandatory=True");
77
+ const desc = descText(p);
78
+ if (desc) tail.push(`desc=${pyStr(desc)}`);
79
+ const hasDef = p.hasDefault && p.default !== undefined;
80
+ const def = p.default;
81
+
82
+ switch (p.kind) {
83
+ case "path":
84
+ return call("File", ["exists=True", ...tail]);
85
+ case "bool":
86
+ return call("traits.Bool", [
87
+ ...(hasDef ? [renderPyLiteral(def!), "usedefault=True"] : []),
88
+ ...tail,
89
+ ]);
90
+ case "count":
91
+ return call("traits.Int", [
92
+ ...(hasDef ? [renderPyLiteral(def!), "usedefault=True"] : []),
93
+ ...tail,
94
+ ]);
95
+ case "int":
96
+ case "float": {
97
+ const asFloat = p.kind === "float";
98
+ if (p.range) {
99
+ const a: string[] = [];
100
+ if (hasDef) a.push(`value=${renderNum(def!, asFloat)}`);
101
+ if (p.range.min !== undefined) a.push(`low=${renderNum(p.range.min, asFloat)}`);
102
+ if (p.range.max !== undefined) a.push(`high=${renderNum(p.range.max, asFloat)}`);
103
+ if (hasDef) a.push("usedefault=True");
104
+ return call("traits.Range", [...a, ...tail]);
105
+ }
106
+ const ctor = p.kind === "int" ? "traits.Int" : "traits.Float";
107
+ return call(ctor, [
108
+ ...(hasDef ? [renderNum(def!, asFloat), "usedefault=True"] : []),
109
+ ...tail,
110
+ ]);
111
+ }
112
+ case "str":
113
+ return call("traits.Str", [
114
+ ...(hasDef ? [renderPyLiteral(def!), "usedefault=True"] : []),
115
+ ...tail,
116
+ ]);
117
+ case "enum": {
118
+ const choices = p.choices ?? [];
119
+ // nipype's first Enum arg is the default; put the styx default first.
120
+ const ordered = hasDef ? [def!, ...choices.filter((c) => c !== def)] : choices;
121
+ return call("traits.Enum", [
122
+ ...ordered.map((c) => renderPyLiteral(c)),
123
+ ...(hasDef ? ["usedefault=True"] : []),
124
+ ...tail,
125
+ ]);
126
+ }
127
+ case "list": {
128
+ const inner = renderItemTrait(p.itemType);
129
+ const bounds: string[] = [];
130
+ if (p.listBounds?.min !== undefined) bounds.push(`minlen=${p.listBounds.min}`);
131
+ if (p.listBounds?.max !== undefined) bounds.push(`maxlen=${p.listBounds.max}`);
132
+ return call("traits.List", [inner, ...bounds, ...tail]);
133
+ }
134
+ case "struct":
135
+ case "union":
136
+ return call("traits.Any", tail);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Map an output field to its nipype output trait expression. `isRoot` is the
142
+ * synthetic output directory, typed as a `Directory` rather than a `File`.
143
+ */
144
+ function renderOutputTrait(f: OutputField, isRoot: boolean): string {
145
+ const tail = f.doc ? [`desc=${pyStr(f.doc)}`] : [];
146
+ if (f.shape.kind === "list") return call("traits.List", ["File()", ...tail]);
147
+ if (isRoot) return call("Directory", tail);
148
+ return call("File", tail);
149
+ }
150
+
151
+ /**
152
+ * Emit the nipype interface module for one tool: a typed InputSpec/OutputSpec and
153
+ * a BaseInterface that delegates execution to the co-emitted styx Python wrapper.
154
+ */
155
+ export function emitNipypeInterface(
156
+ ctx: CodegenContext,
157
+ spec: TypedSpec,
158
+ names: NipypeNames,
159
+ ): string {
160
+ const cb = new CodeBuilder(" ");
161
+ cb.comment("This file was auto generated by Styx.", "# ");
162
+ cb.comment("Do not edit this file directly.", "# ");
163
+ cb.blank();
164
+
165
+ cb.line("from nipype.interfaces.base import (");
166
+ cb.indent(() => {
167
+ cb.line("BaseInterface,");
168
+ cb.line("BaseInterfaceInputSpec,");
169
+ cb.line("Directory,");
170
+ cb.line("File,");
171
+ cb.line("TraitedSpec,");
172
+ cb.line("isdefined,");
173
+ cb.line("traits,");
174
+ });
175
+ cb.line(")");
176
+ cb.blank();
177
+ cb.line(
178
+ `from .${names.styxStem} import ${spec.delegation.wrapperFn}, ${spec.delegation.outputsClass}`,
179
+ );
180
+ cb.blank();
181
+ cb.blank();
182
+
183
+ // InputSpec
184
+ cb.line(`class ${names.inputSpec}(BaseInterfaceInputSpec):`);
185
+ cb.indent(() => {
186
+ if (!spec.rootIsStruct || spec.params.length === 0) {
187
+ cb.line("pass");
188
+ return;
189
+ }
190
+ for (const p of spec.params) {
191
+ cb.line(`${p.hostName} = ${renderInputTrait(p)}`);
192
+ }
193
+ });
194
+ cb.blank();
195
+ cb.blank();
196
+
197
+ // OutputSpec
198
+ cb.line(`class ${names.outputSpec}(TraitedSpec):`);
199
+ cb.indent(() => {
200
+ if (spec.outputs.length === 0 && spec.streams.length === 0) {
201
+ cb.line("pass");
202
+ return;
203
+ }
204
+ spec.outputs.forEach((f, i) => cb.line(`${f.id} = ${renderOutputTrait(f, i === 0)}`));
205
+ for (const s of spec.streams) {
206
+ const tail = s.doc ? `, desc=${pyStr(s.doc)}` : "";
207
+ cb.line(`${s.id} = traits.List(traits.Str()${tail})`);
208
+ }
209
+ });
210
+ cb.blank();
211
+ cb.blank();
212
+
213
+ // Interface
214
+ cb.line(`class ${names.cls}(BaseInterface):`);
215
+ cb.indent(() => {
216
+ const docText = [ctx.app?.doc?.title, ctx.app?.doc?.description].filter(Boolean).join("\n\n");
217
+ emitDocstring(cb, docText || undefined);
218
+ cb.line(`input_spec = ${names.inputSpec}`);
219
+ cb.line(`output_spec = ${names.outputSpec}`);
220
+ cb.blank();
221
+
222
+ cb.line("def _run_interface(self, runtime):");
223
+ cb.indent(() => {
224
+ if (!spec.rootIsStruct) {
225
+ cb.line("raise NotImplementedError(");
226
+ cb.indent(() =>
227
+ cb.line(pyStr("styx nipype backend: tools with a non-struct root are not supported.")),
228
+ );
229
+ cb.line(")");
230
+ return;
231
+ }
232
+ cb.line("kwargs = {}");
233
+ for (const p of spec.params) {
234
+ if (p.mandatory) {
235
+ cb.line(`kwargs[${pyStr(p.hostName)}] = self.inputs.${p.hostName}`);
236
+ } else {
237
+ cb.line(`if isdefined(self.inputs.${p.hostName}):`);
238
+ cb.indent(() => cb.line(`kwargs[${pyStr(p.hostName)}] = self.inputs.${p.hostName}`));
239
+ }
240
+ }
241
+ cb.line(
242
+ `self._result: ${spec.delegation.outputsClass} = ${spec.delegation.wrapperFn}(**kwargs)`,
243
+ );
244
+ cb.line("return runtime");
245
+ });
246
+ cb.blank();
247
+
248
+ cb.line("def _list_outputs(self):");
249
+ cb.indent(() => {
250
+ if (!spec.rootIsStruct) {
251
+ cb.line("return self._outputs().get()");
252
+ return;
253
+ }
254
+ cb.line("result = self._result");
255
+ cb.line("outputs = self._outputs().get()");
256
+ for (const f of spec.outputs) {
257
+ if (f.shape.kind === "single" && f.shape.optional) {
258
+ cb.line(`if result.${f.id} is not None:`);
259
+ cb.indent(() => cb.line(`outputs[${pyStr(f.id)}] = result.${f.id}`));
260
+ } else {
261
+ cb.line(`outputs[${pyStr(f.id)}] = result.${f.id}`);
262
+ }
263
+ }
264
+ for (const s of spec.streams) cb.line(`outputs[${pyStr(s.id)}] = result.${s.id}`);
265
+ cb.line("return outputs");
266
+ });
267
+ });
268
+
269
+ return cb.toString();
270
+ }
@@ -0,0 +1,3 @@
1
+ export type { NipypeNames } from "./emit.js";
2
+ export { emitNipypeInterface, renderInputTrait } from "./emit.js";
3
+ export { generateNipype, nipypeNames, NipypeBackend } from "./nipype.js";
@@ -0,0 +1,70 @@
1
+ import type { CodegenContext } from "../../manifest/index.js";
2
+ import type { Backend, EmittedApp, EmitWarning } from "../backend.js";
3
+ import type { Scope } from "../scope.js";
4
+ import { appModuleName, generatePython } from "../python/index.js";
5
+ import { pascalCase } from "../string-case.js";
6
+ import { buildTypedSpec } from "../typed-spec.js";
7
+ import { emitNipypeInterface, type NipypeNames } from "./emit.js";
8
+
9
+ /** Derive the per-tool nipype module/class names. */
10
+ export function nipypeNames(ctx: CodegenContext): NipypeNames {
11
+ const mod = appModuleName(ctx.app);
12
+ const rawId = ctx.app?.id ?? "tool";
13
+ // Mirror the Python backend's digit-leading-id prescrub so the derived class
14
+ // name is a valid Python identifier (e.g. `3dPFM` -> `V3DPfm`).
15
+ const safeId = /^[0-9]/.test(rawId) ? "v_" + rawId : rawId;
16
+ const cls = pascalCase(safeId);
17
+ return {
18
+ styxStem: `_${mod}`,
19
+ ifaceStem: mod,
20
+ cls,
21
+ inputSpec: `${cls}InputSpec`,
22
+ outputSpec: `${cls}OutputSpec`,
23
+ };
24
+ }
25
+
26
+ /** Generate the nipype interface module source for one tool. */
27
+ export function generateNipype(ctx: CodegenContext): string {
28
+ return emitNipypeInterface(ctx, buildTypedSpec(ctx), nipypeNames(ctx));
29
+ }
30
+
31
+ /**
32
+ * Emits nipype `Interface` definitions whose typed InputSpec/OutputSpec carry
33
+ * rich constraints (numeric ranges, list bounds, enum choices, file types) and
34
+ * which delegate execution to the styx Python wrapper (Option B): no command-line
35
+ * arg-building or output-path resolution is re-implemented here.
36
+ *
37
+ * Per tool, two co-located files are emitted so the output is a self-contained,
38
+ * importable Python package: `_<tool>.py` (the styx Python module) and
39
+ * `<tool>.py` (the interface, importing the wrapper via a relative import).
40
+ */
41
+ export class NipypeBackend implements Backend {
42
+ readonly name = "nipype";
43
+ readonly target = "nipype";
44
+
45
+ // `scope` is intentionally unused: each tool is emitted as its own module
46
+ // (no flat re-export barrel), so per-tool fresh scoping keeps the styx module's
47
+ // kwarg names and the typed spec's host names identical by construction.
48
+ // Suite-level shared scoping is a follow-up alongside emitPackage/emitProject.
49
+ emitApp(ctx: CodegenContext, _scope?: Scope): EmittedApp {
50
+ const names = nipypeNames(ctx);
51
+ const spec = buildTypedSpec(ctx);
52
+ const warnings: EmitWarning[] = [];
53
+ if (!spec.rootIsStruct) {
54
+ warnings.push({
55
+ message: `nipype: '${ctx.app?.id ?? "?"}' has a non-struct root; emitted interface has no typed inputs and raises on run.`,
56
+ });
57
+ }
58
+ const styxCode = generatePython(ctx);
59
+ const ifaceCode = emitNipypeInterface(ctx, spec, names);
60
+ return {
61
+ meta: ctx.app,
62
+ files: new Map([
63
+ [`${names.styxStem}.py`, styxCode],
64
+ [`${names.ifaceStem}.py`, ifaceCode],
65
+ ]),
66
+ errors: [],
67
+ warnings,
68
+ };
69
+ }
70
+ }