@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,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,3 @@
1
+ export type { PydraNames } from "./emit.js";
2
+ export { emitPydraTask } from "./emit.js";
3
+ export { generatePydra, PydraBackend, pydraNames } from "./pydra.js";
@@ -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
- * Root `pyproject.toml`: a metapackage depending on each per-suite distribution
116
- * plus the container/graph runner packages. `packages = []` keeps setuptools
117
- * from sweeping the sibling suite directories into this distribution.
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 generateRootPyproject(proj: ProjectMeta, distNames: string[]): string {
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("packages = []");
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 { AppEntrypoint, Backend, EmitResult, EmittedApp, EmittedPackage } from "../backend.js";
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
- files.set("pyproject.toml", generateRootPyproject(proj, distNames));
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
- const repeat = node?.kind === "repeat" ? node : undefined;
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" }>): JsonSchema {
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
- return { oneOf: type.variants.map((v: BoundVariant) => this.fromType(v.type)) };
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 distribution pulls in (container +
18
- * graph runners). Left unpinned - styxdefs's floor constrains them transitively
19
- * via their own inter-package pins.
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 = ["styxdocker", "styxsingularity", "styxgraph"] as const;
23
+ export const PYTHON_RUNNER_DEPS = ["styxkit[all]"] as const;