@styx-api/core 0.4.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 +966 -249
- 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 +959 -250
- 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
|
@@ -0,0 +1,318 @@
|
|
|
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 pydra task module. */
|
|
9
|
+
export interface PydraNames {
|
|
10
|
+
/** Styx Python module file stem to import the wrapper from (e.g. `_bet`). */
|
|
11
|
+
styxStem: string;
|
|
12
|
+
/** Task module file stem (e.g. `bet`). */
|
|
13
|
+
ifaceStem: string;
|
|
14
|
+
/** Task class name (e.g. `Bet`). */
|
|
15
|
+
cls: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Which optional imports the emitted module needs. */
|
|
19
|
+
interface Imports {
|
|
20
|
+
typing: boolean;
|
|
21
|
+
attrs: boolean;
|
|
22
|
+
file: boolean;
|
|
23
|
+
directory: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A scalar enum's Python type: int when every choice is numeric, else str. */
|
|
27
|
+
function enumScalar(choices: readonly (string | number)[] | undefined): string {
|
|
28
|
+
return (choices ?? []).every((c) => typeof c === "number") ? "int" : "str";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when a parameter is a file path (a path scalar or a list of paths). */
|
|
32
|
+
function isPathParam(p: TypedParam): boolean {
|
|
33
|
+
return p.kind === "path" || (p.kind === "list" && p.itemType?.kind === "path");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function itemTypeStr(item: TypedParamItem | undefined, imp: Imports): string {
|
|
37
|
+
if (!item) {
|
|
38
|
+
imp.typing = true;
|
|
39
|
+
return "ty.Any";
|
|
40
|
+
}
|
|
41
|
+
switch (item.kind) {
|
|
42
|
+
case "path":
|
|
43
|
+
imp.file = true;
|
|
44
|
+
return "File";
|
|
45
|
+
case "int":
|
|
46
|
+
case "count":
|
|
47
|
+
return "int";
|
|
48
|
+
case "float":
|
|
49
|
+
return "float";
|
|
50
|
+
case "str":
|
|
51
|
+
return "str";
|
|
52
|
+
case "bool":
|
|
53
|
+
return "bool";
|
|
54
|
+
case "enum":
|
|
55
|
+
return enumScalar(item.choices);
|
|
56
|
+
default:
|
|
57
|
+
imp.typing = true;
|
|
58
|
+
return "ty.Any";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Base (non-optional) Python type expression for a parameter. */
|
|
63
|
+
function baseType(p: TypedParam, imp: Imports): string {
|
|
64
|
+
switch (p.kind) {
|
|
65
|
+
case "path":
|
|
66
|
+
imp.file = true;
|
|
67
|
+
return "File";
|
|
68
|
+
case "int":
|
|
69
|
+
case "count":
|
|
70
|
+
return "int";
|
|
71
|
+
case "float":
|
|
72
|
+
return "float";
|
|
73
|
+
case "str":
|
|
74
|
+
return "str";
|
|
75
|
+
case "bool":
|
|
76
|
+
return "bool";
|
|
77
|
+
case "enum":
|
|
78
|
+
return enumScalar(p.choices);
|
|
79
|
+
case "list":
|
|
80
|
+
return `list[${itemTypeStr(p.itemType, imp)}]`;
|
|
81
|
+
case "struct":
|
|
82
|
+
case "union":
|
|
83
|
+
imp.typing = true;
|
|
84
|
+
return "ty.Any";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Full input type, wrapping in `ty.Optional[...]` for an omittable-no-default field. */
|
|
89
|
+
function inputType(p: TypedParam, imp: Imports): string {
|
|
90
|
+
const base = baseType(p, imp);
|
|
91
|
+
if (p.optional && !p.hasDefault) {
|
|
92
|
+
imp.typing = true;
|
|
93
|
+
return `ty.Optional[${base}]`;
|
|
94
|
+
}
|
|
95
|
+
return base;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An attrs validator enforcing numeric range / list-length bounds, or undefined. */
|
|
99
|
+
function validatorExpr(p: TypedParam, imp: Imports): string | undefined {
|
|
100
|
+
const checks: string[] = [];
|
|
101
|
+
if (p.range) {
|
|
102
|
+
if (p.range.min !== undefined)
|
|
103
|
+
checks.push(`attrs.validators.ge(${renderPyLiteral(p.range.min)})`);
|
|
104
|
+
if (p.range.max !== undefined)
|
|
105
|
+
checks.push(`attrs.validators.le(${renderPyLiteral(p.range.max)})`);
|
|
106
|
+
}
|
|
107
|
+
if (p.listBounds) {
|
|
108
|
+
if (p.listBounds.min !== undefined)
|
|
109
|
+
checks.push(`attrs.validators.min_len(${p.listBounds.min})`);
|
|
110
|
+
if (p.listBounds.max !== undefined)
|
|
111
|
+
checks.push(`attrs.validators.max_len(${p.listBounds.max})`);
|
|
112
|
+
}
|
|
113
|
+
if (checks.length === 0) return undefined;
|
|
114
|
+
imp.attrs = true;
|
|
115
|
+
const inner = checks.length === 1 ? checks[0]! : `attrs.validators.and_(${checks.join(", ")})`;
|
|
116
|
+
// Skip validation for unset (attrs.NOTHING) / None values. A bare attrs
|
|
117
|
+
// validator runs against the NOTHING sentinel of an unset mandatory field and
|
|
118
|
+
// would crash construction; `_styx_optional` guards both NOTHING and None
|
|
119
|
+
// (attrs.validators.optional only guards None).
|
|
120
|
+
return `_styx_optional(${inner})`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Help text: doc + degrade / media-type notes. */
|
|
124
|
+
function helpText(p: TypedParam): string | undefined {
|
|
125
|
+
const parts: string[] = [];
|
|
126
|
+
if (p.doc) parts.push(p.doc);
|
|
127
|
+
if (p.kind === "struct" || p.kind === "union") parts.push("(nested configuration; pass a dict)");
|
|
128
|
+
if (p.mediaTypes && p.mediaTypes.length > 0) {
|
|
129
|
+
parts.push(`(media types: ${p.mediaTypes.join(", ")})`);
|
|
130
|
+
}
|
|
131
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Render a `python.arg(...)` field for one parameter. */
|
|
135
|
+
function renderInputArg(p: TypedParam, imp: Imports): string {
|
|
136
|
+
const args: string[] = [`type=${inputType(p, imp)}`];
|
|
137
|
+
if (p.mandatory) {
|
|
138
|
+
// no default -> mandatory
|
|
139
|
+
} else if (p.optional && !p.hasDefault) {
|
|
140
|
+
args.push("default=None");
|
|
141
|
+
} else {
|
|
142
|
+
args.push(`default=${renderPyLiteral(p.default!)}`);
|
|
143
|
+
}
|
|
144
|
+
if (p.kind === "enum") {
|
|
145
|
+
args.push(`allowed_values=[${(p.choices ?? []).map((c) => renderPyLiteral(c)).join(", ")}]`);
|
|
146
|
+
}
|
|
147
|
+
const v = validatorExpr(p, imp);
|
|
148
|
+
if (v) args.push(`validator=${v}`);
|
|
149
|
+
const h = helpText(p);
|
|
150
|
+
if (h) args.push(`help=${pyStr(h)}`);
|
|
151
|
+
return `python.arg(${args.join(", ")})`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Python type for an output field. `isRoot` is the synthetic output directory. */
|
|
155
|
+
function outputType(f: OutputField, isRoot: boolean, imp: Imports): string {
|
|
156
|
+
if (f.shape.kind === "list") {
|
|
157
|
+
imp.file = true;
|
|
158
|
+
return "list[File]";
|
|
159
|
+
}
|
|
160
|
+
if (isRoot) {
|
|
161
|
+
imp.directory = true;
|
|
162
|
+
return "Directory";
|
|
163
|
+
}
|
|
164
|
+
imp.file = true;
|
|
165
|
+
if (f.shape.optional) {
|
|
166
|
+
imp.typing = true;
|
|
167
|
+
return "ty.Optional[File]";
|
|
168
|
+
}
|
|
169
|
+
return "File";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function renderOutputArg(f: OutputField, isRoot: boolean, imp: Imports): string {
|
|
173
|
+
const args: string[] = [`type=${outputType(f, isRoot, imp)}`];
|
|
174
|
+
if (f.doc) args.push(`help=${pyStr(f.doc)}`);
|
|
175
|
+
return `python.out(${args.join(", ")})`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Emit the pydra task module for one tool: a `@python.define` task whose typed
|
|
180
|
+
* inputs/outputs carry rich constraints and whose body delegates execution to the
|
|
181
|
+
* co-emitted styx Python wrapper (Option B). Targets the post-rewrite
|
|
182
|
+
* `pydra.compose` API (pydra >= 1.0a, Python >= 3.11).
|
|
183
|
+
*/
|
|
184
|
+
export function emitPydraTask(ctx: CodegenContext, spec: TypedSpec, names: PydraNames): string {
|
|
185
|
+
const imp: Imports = { typing: false, attrs: false, file: false, directory: false };
|
|
186
|
+
|
|
187
|
+
// Render fields first so we know which imports are needed.
|
|
188
|
+
const inputEntries = spec.params.map((p) => ({
|
|
189
|
+
key: p.hostName,
|
|
190
|
+
expr: renderInputArg(p, imp),
|
|
191
|
+
}));
|
|
192
|
+
const outputEntries = spec.outputs.map((f, i) => ({
|
|
193
|
+
key: f.id,
|
|
194
|
+
expr: renderOutputArg(f, i === 0, imp),
|
|
195
|
+
}));
|
|
196
|
+
for (const s of spec.streams) {
|
|
197
|
+
// list[str] uses builtin generics - no extra import needed.
|
|
198
|
+
outputEntries.push({
|
|
199
|
+
key: s.id,
|
|
200
|
+
expr: `python.out(type=list[str]${s.doc ? `, help=${pyStr(s.doc)}` : ""})`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Path-typed inputs arrive from pydra as fileformats objects; the styx wrapper
|
|
205
|
+
// expects str/pathlib.Path, so convert them at the call boundary.
|
|
206
|
+
const needsPath = spec.params.some(isPathParam);
|
|
207
|
+
|
|
208
|
+
const cb = new CodeBuilder(" ");
|
|
209
|
+
cb.comment("This file was auto generated by Styx.", "# ");
|
|
210
|
+
cb.comment("Do not edit this file directly.", "# ");
|
|
211
|
+
cb.comment("Targets the pydra.compose API (pydra >= 1.0a, Python >= 3.11).", "# ");
|
|
212
|
+
cb.blank();
|
|
213
|
+
|
|
214
|
+
if (needsPath) cb.line("import os");
|
|
215
|
+
if (imp.typing) cb.line("import typing as ty");
|
|
216
|
+
if (imp.attrs) cb.line("import attrs.validators");
|
|
217
|
+
cb.line("from pydra.compose import python");
|
|
218
|
+
const ff: string[] = [];
|
|
219
|
+
if (imp.directory) ff.push("Directory");
|
|
220
|
+
if (imp.file) ff.push("File");
|
|
221
|
+
if (ff.length > 0) cb.line(`from fileformats.generic import ${ff.join(", ")}`);
|
|
222
|
+
cb.blank();
|
|
223
|
+
cb.line(`from .${names.styxStem} import ${spec.delegation.wrapperFn}`);
|
|
224
|
+
cb.blank();
|
|
225
|
+
cb.blank();
|
|
226
|
+
|
|
227
|
+
if (imp.attrs) {
|
|
228
|
+
cb.line("def _styx_optional(validator):");
|
|
229
|
+
cb.indent(() => {
|
|
230
|
+
cb.line('"""Apply an attrs validator only to a set (non-NOTHING), non-None value."""');
|
|
231
|
+
cb.line("def _check(instance, attribute, value):");
|
|
232
|
+
cb.indent(() => {
|
|
233
|
+
cb.line("if value is attrs.NOTHING or value is None:");
|
|
234
|
+
cb.indent(() => cb.line("return"));
|
|
235
|
+
cb.line("validator(instance, attribute, value)");
|
|
236
|
+
});
|
|
237
|
+
cb.line("return _check");
|
|
238
|
+
});
|
|
239
|
+
cb.blank();
|
|
240
|
+
cb.blank();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (needsPath) {
|
|
244
|
+
cb.line("def _styx_path(value):");
|
|
245
|
+
cb.indent(() => {
|
|
246
|
+
cb.line('"""Convert fileformats / Path inputs into a styxdefs-accepted path."""');
|
|
247
|
+
cb.line("if value is None:");
|
|
248
|
+
cb.indent(() => cb.line("return None"));
|
|
249
|
+
cb.line("if isinstance(value, (list, tuple)):");
|
|
250
|
+
cb.indent(() => cb.line("return [os.fspath(v) for v in value]"));
|
|
251
|
+
cb.line("return os.fspath(value)");
|
|
252
|
+
});
|
|
253
|
+
cb.blank();
|
|
254
|
+
cb.blank();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Decorator
|
|
258
|
+
cb.line("@python.define(");
|
|
259
|
+
cb.indent(() => {
|
|
260
|
+
if (inputEntries.length === 0) {
|
|
261
|
+
cb.line("inputs={},");
|
|
262
|
+
} else {
|
|
263
|
+
cb.line("inputs={");
|
|
264
|
+
cb.indent(() => {
|
|
265
|
+
for (const e of inputEntries) cb.line(`${pyStr(e.key)}: ${e.expr},`);
|
|
266
|
+
});
|
|
267
|
+
cb.line("},");
|
|
268
|
+
}
|
|
269
|
+
cb.line("outputs={");
|
|
270
|
+
cb.indent(() => {
|
|
271
|
+
for (const e of outputEntries) cb.line(`${pyStr(e.key)}: ${e.expr},`);
|
|
272
|
+
});
|
|
273
|
+
cb.line("},");
|
|
274
|
+
});
|
|
275
|
+
cb.line(")");
|
|
276
|
+
|
|
277
|
+
// Function
|
|
278
|
+
const paramList = spec.params.map((p) => p.hostName);
|
|
279
|
+
cb.line(`def ${names.cls}(${paramList.join(", ")}):`);
|
|
280
|
+
cb.indent(() => {
|
|
281
|
+
const docText = [ctx.app?.doc?.title, ctx.app?.doc?.description].filter(Boolean).join("\n\n");
|
|
282
|
+
emitDocstring(cb, docText || undefined);
|
|
283
|
+
|
|
284
|
+
if (!spec.rootIsStruct) {
|
|
285
|
+
cb.line("raise NotImplementedError(");
|
|
286
|
+
cb.indent(() =>
|
|
287
|
+
cb.line(pyStr("styx pydra backend: tools with a non-struct root are not supported.")),
|
|
288
|
+
);
|
|
289
|
+
cb.line(")");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (spec.params.length === 0) {
|
|
294
|
+
cb.line(`result = ${spec.delegation.wrapperFn}()`);
|
|
295
|
+
} else {
|
|
296
|
+
cb.line(`result = ${spec.delegation.wrapperFn}(`);
|
|
297
|
+
cb.indent(() => {
|
|
298
|
+
for (const p of spec.params) {
|
|
299
|
+
const expr = isPathParam(p) ? `_styx_path(${p.hostName})` : p.hostName;
|
|
300
|
+
cb.line(`${p.hostName}=${expr},`);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
cb.line(")");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const returns = [
|
|
307
|
+
...spec.outputs.map((f) => `result.${f.id}`),
|
|
308
|
+
...spec.streams.map((s) => `result.${s.id}`),
|
|
309
|
+
];
|
|
310
|
+
if (returns.length === 1) {
|
|
311
|
+
cb.line(`return ${returns[0]}`);
|
|
312
|
+
} else {
|
|
313
|
+
cb.line(`return ${returns.join(", ")}`);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return cb.toString();
|
|
318
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { emitPydraTask, type PydraNames } from "./emit.js";
|
|
8
|
+
|
|
9
|
+
/** Derive the per-tool pydra module/class names. */
|
|
10
|
+
export function pydraNames(ctx: CodegenContext): PydraNames {
|
|
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 task class name
|
|
14
|
+
// is a valid Python identifier (e.g. `3dPFM` -> `V3DPfm`).
|
|
15
|
+
const safeId = /^[0-9]/.test(rawId) ? "v_" + rawId : rawId;
|
|
16
|
+
return {
|
|
17
|
+
styxStem: `_${mod}`,
|
|
18
|
+
ifaceStem: mod,
|
|
19
|
+
cls: pascalCase(safeId),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Generate the pydra task module source for one tool. */
|
|
24
|
+
export function generatePydra(ctx: CodegenContext): string {
|
|
25
|
+
return emitPydraTask(ctx, buildTypedSpec(ctx), pydraNames(ctx));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Emits pydra tasks (`@python.define`, the post-rewrite pydra.compose API) whose
|
|
30
|
+
* typed inputs/outputs carry rich constraints (numeric ranges and list bounds via
|
|
31
|
+
* attrs validators, enum choices, file types, defaults) and which delegate
|
|
32
|
+
* execution to the styx Python wrapper (Option B): no command-line arg-building
|
|
33
|
+
* or output-path resolution is re-implemented here.
|
|
34
|
+
*
|
|
35
|
+
* Per tool, two co-located files are emitted so the output is a self-contained,
|
|
36
|
+
* importable Python package: `_<tool>.py` (the styx Python module) and
|
|
37
|
+
* `<tool>.py` (the task, importing the wrapper via a relative import).
|
|
38
|
+
*/
|
|
39
|
+
export class PydraBackend implements Backend {
|
|
40
|
+
readonly name = "pydra";
|
|
41
|
+
readonly target = "pydra";
|
|
42
|
+
|
|
43
|
+
// `scope` is intentionally unused: each tool is emitted as its own module, so
|
|
44
|
+
// per-tool fresh scoping keeps the styx module's kwarg names and the typed
|
|
45
|
+
// spec's host names identical by construction (see NipypeBackend).
|
|
46
|
+
emitApp(ctx: CodegenContext, _scope?: Scope): EmittedApp {
|
|
47
|
+
const names = pydraNames(ctx);
|
|
48
|
+
const spec = buildTypedSpec(ctx);
|
|
49
|
+
const warnings: EmitWarning[] = [];
|
|
50
|
+
if (!spec.rootIsStruct) {
|
|
51
|
+
warnings.push({
|
|
52
|
+
message: `pydra: '${ctx.app?.id ?? "?"}' has a non-struct root; emitted task has no typed inputs and raises on run.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const styxCode = generatePython(ctx);
|
|
56
|
+
const taskCode = emitPydraTask(ctx, spec, names);
|
|
57
|
+
return {
|
|
58
|
+
meta: ctx.app,
|
|
59
|
+
files: new Map([
|
|
60
|
+
[`${names.styxStem}.py`, styxCode],
|
|
61
|
+
[`${names.ifaceStem}.py`, taskCode],
|
|
62
|
+
]),
|
|
63
|
+
errors: [],
|
|
64
|
+
warnings,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
export type { PublicNames } from "./python.js";
|
|
1
|
+
export type { PublicNames, PyEmitModel } from "./python.js";
|
|
2
2
|
export {
|
|
3
3
|
appModuleName,
|
|
4
|
+
buildEmitModel,
|
|
4
5
|
computePublicNames,
|
|
5
6
|
generatePackageInit,
|
|
6
7
|
generatePython,
|
|
7
8
|
PythonBackend,
|
|
8
9
|
} from "./python.js";
|
|
10
|
+
// Output-field id sanitizer, reused by the nipype/pydra delegation spec so the
|
|
11
|
+
// generated specs reference the exact dataclass attribute names the Python
|
|
12
|
+
// Outputs object exposes.
|
|
13
|
+
export { pyId } from "./outputs-emit.js";
|
|
9
14
|
export { renderPythonCall } from "./snippet.js";
|
|
@@ -394,7 +394,7 @@ export function emitBuildOutputs(
|
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
/** Sanitize an output name to a valid Python identifier. */
|
|
397
|
-
function pyId(name: string): string {
|
|
397
|
+
export function pyId(name: string): string {
|
|
398
398
|
let s = name.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
399
399
|
if (/^\d/.test(s)) s = "_" + s;
|
|
400
400
|
if (s === "") s = "_";
|
|
@@ -112,11 +112,36 @@ export function generateSubPyproject(proj: ProjectMeta, pkg: PackageMeta): strin
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
115
|
+
* The metapackage's `__init__.py`. A thin re-export of styxkit so the runner
|
|
116
|
+
* configuration helpers (`use_docker`, `use_local`, `set_global_runner`, ...)
|
|
117
|
+
* are reachable as `<project>.<name>` - the v1 ergonomic that the v2 split into
|
|
118
|
+
* per-suite distributions otherwise dropped. The logic lives in styxkit (pulled
|
|
119
|
+
* in via `styxkit[all]`), so this stays a wildcard re-export and never re-emits
|
|
120
|
+
* the runner-config code itself.
|
|
118
121
|
*/
|
|
119
|
-
export function
|
|
122
|
+
export function generateRootInitPy(): string {
|
|
123
|
+
return (
|
|
124
|
+
"# This file was auto generated by Styx.\n" +
|
|
125
|
+
"# Do not edit this file directly.\n" +
|
|
126
|
+
"\n" +
|
|
127
|
+
"# Re-export styxkit's runner-configuration helpers (use_docker, use_local,\n" +
|
|
128
|
+
"# use_auto, set_global_runner, get_global_runner, ...) so they are available\n" +
|
|
129
|
+
"# directly on this package, e.g. `import niwrap; niwrap.use_docker()`.\n" +
|
|
130
|
+
"from styxkit import * # noqa: F401,F403\n"
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Root `pyproject.toml`: a metapackage depending on `styxkit[all]` (the runner
|
|
136
|
+
* stack it re-exports) plus each per-suite distribution. `packages` lists only
|
|
137
|
+
* the metapackage's own module so setuptools ships the styxkit re-export without
|
|
138
|
+
* sweeping the sibling suite directories into this distribution.
|
|
139
|
+
*/
|
|
140
|
+
export function generateRootPyproject(
|
|
141
|
+
proj: ProjectMeta,
|
|
142
|
+
distNames: string[],
|
|
143
|
+
moduleName: string,
|
|
144
|
+
): string {
|
|
120
145
|
const cb = new CodeBuilder(" ");
|
|
121
146
|
cb.line("[project]");
|
|
122
147
|
cb.line(`name = "${tomlStr(proj.name ?? "project")}"`);
|
|
@@ -132,7 +157,10 @@ export function generateRootPyproject(proj: ProjectMeta, distNames: string[]): s
|
|
|
132
157
|
cb.line("]");
|
|
133
158
|
cb.blank();
|
|
134
159
|
cb.line("[tool.setuptools]");
|
|
135
|
-
cb.line(
|
|
160
|
+
cb.line(`packages = ["${moduleName}"]`);
|
|
161
|
+
cb.blank();
|
|
162
|
+
cb.line("[tool.setuptools.package-data]");
|
|
163
|
+
cb.line(`"${moduleName}" = ["py.typed"]`);
|
|
136
164
|
cb.blank();
|
|
137
165
|
cb.line(BUILD_SYSTEM);
|
|
138
166
|
return cb.toString() + "\n";
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import type { BoundType } from "../../bindings/index.js";
|
|
2
2
|
import type { AppMeta } from "../../ir/index.js";
|
|
3
3
|
import type { CodegenContext, PackageMeta, ProjectMeta } from "../../manifest/index.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
AppEntrypoint,
|
|
6
|
+
Backend,
|
|
7
|
+
EmitResult,
|
|
8
|
+
EmittedApp,
|
|
9
|
+
EmittedPackage,
|
|
10
|
+
EmitWarning,
|
|
11
|
+
} from "../backend.js";
|
|
5
12
|
import type { SigEntry } from "../sig-entries.js";
|
|
6
13
|
import type { NamedType } from "./types.js";
|
|
7
14
|
import {
|
|
8
15
|
generateRequirementsTxt,
|
|
16
|
+
generateRootInitPy,
|
|
9
17
|
generateRootPyproject,
|
|
10
18
|
generateRootReadme,
|
|
11
19
|
generateSubPyproject,
|
|
@@ -537,6 +545,7 @@ export class PythonBackend implements Backend {
|
|
|
537
545
|
const files = new Map<string, string>();
|
|
538
546
|
const distNames: string[] = [];
|
|
539
547
|
const pkgDirs: string[] = [];
|
|
548
|
+
const warnings: EmitWarning[] = [];
|
|
540
549
|
|
|
541
550
|
for (const p of packages) {
|
|
542
551
|
const pkg = p.meta ?? {};
|
|
@@ -549,10 +558,26 @@ export class PythonBackend implements Backend {
|
|
|
549
558
|
files.set(`${dir}/README.md`, generateSubReadme(proj, pkg));
|
|
550
559
|
}
|
|
551
560
|
|
|
552
|
-
|
|
561
|
+
// The metapackage ships its own importable module: a thin styxkit re-export
|
|
562
|
+
// so `import <project>; <project>.use_docker()` works (PEP 561 typed). Scrub
|
|
563
|
+
// the project name to a valid, non-keyword identifier, then dodge any suite
|
|
564
|
+
// directory so the stub never clobbers a suite's real wrapper module.
|
|
565
|
+
const rawMod = proj.name && proj.name.trim() ? proj.name : "project";
|
|
566
|
+
let rootMod = pyScrubIdent(rawMod, PY_RESERVED);
|
|
567
|
+
if (pkgDirs.includes(rootMod)) {
|
|
568
|
+
const collided = rootMod;
|
|
569
|
+
while (pkgDirs.includes(rootMod)) rootMod += "_";
|
|
570
|
+
warnings.push({
|
|
571
|
+
message: `metapackage module "${collided}" collides with a suite directory; emitting as "${rootMod}" instead`,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
files.set(`${rootMod}/__init__.py`, generateRootInitPy());
|
|
575
|
+
files.set(`${rootMod}/py.typed`, "");
|
|
576
|
+
|
|
577
|
+
files.set("pyproject.toml", generateRootPyproject(proj, distNames, rootMod));
|
|
553
578
|
files.set("README.md", generateRootReadme(proj, distNames));
|
|
554
579
|
files.set("requirements.txt", generateRequirementsTxt(pkgDirs));
|
|
555
580
|
|
|
556
|
-
return { files, errors: [], warnings
|
|
581
|
+
return { files, errors: [], warnings };
|
|
557
582
|
}
|
|
558
583
|
}
|
|
@@ -8,6 +8,7 @@ import { findStructNode } from "../find-struct-node.js";
|
|
|
8
8
|
import { resolveFieldBinding } from "../resolve-field-binding.js";
|
|
9
9
|
import { Scope } from "../scope.js";
|
|
10
10
|
import { snakeCase } from "../string-case.js";
|
|
11
|
+
import { findAlternativeNode, findRepeatNode } from "../validate-walk.js";
|
|
11
12
|
|
|
12
13
|
export interface JsonSchema {
|
|
13
14
|
type?: string | string[];
|
|
@@ -69,7 +70,11 @@ class SchemaBuilder {
|
|
|
69
70
|
case "optional":
|
|
70
71
|
return this.fromType(type.inner, node?.kind === "optional" ? node.attrs.node : undefined);
|
|
71
72
|
case "list": {
|
|
72
|
-
|
|
73
|
+
// Descend through any optional/flag-sequence wrappers to the repeat node
|
|
74
|
+
// (matching the TS/Python backends). A naive `node.kind === "repeat"`
|
|
75
|
+
// check only matches a bare top-level list and silently drops the bounds
|
|
76
|
+
// (and item-level scalar ranges) for the common optional/flagged shape.
|
|
77
|
+
const repeat = findRepeatNode(node);
|
|
73
78
|
const schema: JsonSchema = {
|
|
74
79
|
type: "array",
|
|
75
80
|
items: this.fromType(type.item, repeat?.attrs.node),
|
|
@@ -83,7 +88,7 @@ class SchemaBuilder {
|
|
|
83
88
|
case "struct":
|
|
84
89
|
return this.structSchema(type, node);
|
|
85
90
|
case "union":
|
|
86
|
-
return this.unionSchema(type);
|
|
91
|
+
return this.unionSchema(type, node);
|
|
87
92
|
}
|
|
88
93
|
}
|
|
89
94
|
|
|
@@ -167,7 +172,7 @@ class SchemaBuilder {
|
|
|
167
172
|
return schema;
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
private unionSchema(type: Extract<BoundType, { kind: "union" }
|
|
175
|
+
private unionSchema(type: Extract<BoundType, { kind: "union" }>, node?: Expr): JsonSchema {
|
|
171
176
|
const allLiterals = type.variants.every((v: BoundVariant) => v.type.kind === "literal");
|
|
172
177
|
if (allLiterals) {
|
|
173
178
|
return {
|
|
@@ -176,7 +181,32 @@ class SchemaBuilder {
|
|
|
176
181
|
),
|
|
177
182
|
};
|
|
178
183
|
}
|
|
179
|
-
|
|
184
|
+
// Thread each arm's IR node so struct-variant fields keep their constraints
|
|
185
|
+
// (numeric ranges, list bounds). `variants` stays parallel to the IR `alts`
|
|
186
|
+
// (the solver builds them together), so `alts[i]` is variant `i`'s node.
|
|
187
|
+
const altNode = findAlternativeNode(node);
|
|
188
|
+
return {
|
|
189
|
+
oneOf: type.variants.map((v: BoundVariant, i: number) => {
|
|
190
|
+
const schema = this.fromType(v.type, altNode?.attrs.alts[i]);
|
|
191
|
+
// The solver prepends a synthetic `@type` literal to each non-literal
|
|
192
|
+
// variant, but `structSchema`'s node-driven branch builds properties from
|
|
193
|
+
// the IR struct's children, which don't include `@type`. Re-add it (first,
|
|
194
|
+
// required) so each arm still carries its discriminant tag.
|
|
195
|
+
if (
|
|
196
|
+
v.type.kind === "struct" &&
|
|
197
|
+
"@type" in v.type.fields &&
|
|
198
|
+
schema.properties &&
|
|
199
|
+
!("@type" in schema.properties)
|
|
200
|
+
) {
|
|
201
|
+
schema.properties = {
|
|
202
|
+
"@type": this.fromType(v.type.fields["@type"]!),
|
|
203
|
+
...schema.properties,
|
|
204
|
+
};
|
|
205
|
+
schema.required = ["@type", ...(schema.required ?? [])];
|
|
206
|
+
}
|
|
207
|
+
return schema;
|
|
208
|
+
}),
|
|
209
|
+
};
|
|
180
210
|
}
|
|
181
211
|
}
|
|
182
212
|
|
|
@@ -14,8 +14,10 @@ export const STYXDEFS_COMPAT = {
|
|
|
14
14
|
} as const;
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Extra Python runtime packages the root
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Extra Python runtime packages the root metapackage pulls in. `styxkit[all]`
|
|
18
|
+
* provides the cross-backend runner-selection helpers (`use_docker`, `use_auto`,
|
|
19
|
+
* ...) that the metapackage's `__init__` re-exports, and transitively installs
|
|
20
|
+
* every container/graph runner backend. Left unpinned - styxkit's own styxdefs
|
|
21
|
+
* floor constrains the stack.
|
|
20
22
|
*/
|
|
21
|
-
export const PYTHON_RUNNER_DEPS = ["
|
|
23
|
+
export const PYTHON_RUNNER_DEPS = ["styxkit[all]"] as const;
|