@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,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 = "_";
@@ -72,17 +72,23 @@ function licenseField(proj: ProjectMeta): string {
72
72
  }
73
73
 
74
74
  /**
75
- * Per-suite `pyproject.toml`. The flat layout (`python/<pkg>/bet.py`) makes the
76
- * directory itself the importable package, so setuptools' `package-dir` maps the
77
- * import name (`<pkg>`) onto the distribution's root directory. The styxdefs
75
+ * Per-suite `pyproject.toml`. The suite source stays in a flat directory
76
+ * (`python/<pkg>/bet.py`), but setuptools' `package-dir` maps that directory onto
77
+ * the dotted import package `<project>.<pkg>`, so every suite nests under the
78
+ * metapackage's `<project>/` namespace. That restores `from <project> import
79
+ * <pkg>` while keeping each suite a separately-installable distribution (and
80
+ * leaves no top-level `<pkg>` polluting the global namespace). With no project
81
+ * name the import name is the bare `<pkg>` (top-level fallback). The styxdefs
78
82
  * floor is the only runtime dependency.
79
83
  *
80
- * Precondition: `pkg.name` must be a valid Python identifier - the flat layout's
81
- * relative imports (`from .bet import *`) already require this, and the CLI uses
82
- * it verbatim as the directory name, so this stays consistent with that.
84
+ * Precondition: `importName` is a valid (possibly dotted) Python package path -
85
+ * the caller scrubs the project + package names into identifiers accordingly.
83
86
  */
84
- export function generateSubPyproject(proj: ProjectMeta, pkg: PackageMeta): string {
85
- const importName = pkgDir(pkg);
87
+ export function generateSubPyproject(
88
+ proj: ProjectMeta,
89
+ pkg: PackageMeta,
90
+ importName: string,
91
+ ): string {
86
92
  const cb = new CodeBuilder(" ");
87
93
  cb.line("[project]");
88
94
  cb.line(`name = "${tomlStr(pyDistName(proj, pkg))}"`);
@@ -112,11 +118,42 @@ export function generateSubPyproject(proj: ProjectMeta, pkg: PackageMeta): strin
112
118
  }
113
119
 
114
120
  /**
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.
121
+ * The metapackage's `__init__.py`. A thin re-export of styxkit so the runner
122
+ * configuration helpers (`use_docker`, `use_local`, `set_global_runner`, ...)
123
+ * are reachable as `<project>.<name>` - the v1 ergonomic that the v2 split into
124
+ * per-suite distributions otherwise dropped. The logic lives in styxkit (pulled
125
+ * in via `styxkit[all]`), so this stays a wildcard re-export and never re-emits
126
+ * the runner-config code itself.
118
127
  */
119
- export function generateRootPyproject(proj: ProjectMeta, distNames: string[]): string {
128
+ export function generateRootInitPy(): string {
129
+ return (
130
+ "# This file was auto generated by Styx.\n" +
131
+ "# Do not edit this file directly.\n" +
132
+ "\n" +
133
+ "# Re-export styxkit's runner-configuration helpers (use_docker, use_local,\n" +
134
+ "# use_auto, set_global_runner, get_global_runner, ...) so they are available\n" +
135
+ "# directly on this package, e.g. `import niwrap; niwrap.use_docker()`.\n" +
136
+ "from styxkit import * # noqa: F401,F403\n"
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Root `pyproject.toml`: a metapackage depending on `styxkit[all]` (the runner
142
+ * stack it re-exports) plus each per-suite distribution. `packages` lists only
143
+ * the metapackage's own module so setuptools ships the styxkit re-export without
144
+ * sweeping the sibling suite directories into this distribution. The module is
145
+ * the `<project>/` namespace package the suites nest into, so installing the
146
+ * metapackage makes `<project>.use_docker()` reachable alongside the suites'
147
+ * `from <project> import <pkg>`. `moduleDir` is the on-disk source directory; it
148
+ * differs from the import `moduleName` only when a suite is named after the
149
+ * project, in which case a `package-dir` remap keeps the import name intact.
150
+ */
151
+ export function generateRootPyproject(
152
+ proj: ProjectMeta,
153
+ distNames: string[],
154
+ moduleName: string,
155
+ moduleDir: string,
156
+ ): string {
120
157
  const cb = new CodeBuilder(" ");
121
158
  cb.line("[project]");
122
159
  cb.line(`name = "${tomlStr(proj.name ?? "project")}"`);
@@ -132,7 +169,13 @@ export function generateRootPyproject(proj: ProjectMeta, distNames: string[]): s
132
169
  cb.line("]");
133
170
  cb.blank();
134
171
  cb.line("[tool.setuptools]");
135
- cb.line("packages = []");
172
+ cb.line(`packages = ["${moduleName}"]`);
173
+ if (moduleDir !== moduleName) {
174
+ cb.line(`package-dir = { "${moduleName}" = "${moduleDir}" }`);
175
+ }
176
+ cb.blank();
177
+ cb.line("[tool.setuptools.package-data]");
178
+ cb.line(`"${moduleName}" = ["py.typed"]`);
136
179
  cb.blank();
137
180
  cb.line(BUILD_SYSTEM);
138
181
  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,15 @@ 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[] = [];
549
+
550
+ // The metapackage's importable module doubles as the namespace every suite
551
+ // nests under (`<project>.<suite>`), so `from <project> import <suite>` and
552
+ // `<project>.use_docker()` both resolve from one shared `<project>/` package.
553
+ // Scrub the project name to a valid, non-keyword identifier; with no project
554
+ // name there is no namespace and suites stay top-level (matching the bare
555
+ // distribution-name fallback in `pyDistName`).
556
+ const nsName = proj.name && proj.name.trim() ? pyScrubIdent(proj.name, PY_RESERVED) : undefined;
540
557
 
541
558
  for (const p of packages) {
542
559
  const pkg = p.meta ?? {};
@@ -545,14 +562,34 @@ export class PythonBackend implements Backend {
545
562
  const dir = pkg.name ?? "package";
546
563
  pkgDirs.push(dir);
547
564
  distNames.push(pyDistName(proj, pkg));
548
- files.set(`${dir}/pyproject.toml`, generateSubPyproject(proj, pkg));
565
+ // Import package: `<project>.<suite>` so it shares the metapackage's
566
+ // namespace; setuptools maps the flat suite dir onto this dotted path.
567
+ const importPkg = nsName ? `${nsName}.${dir}` : dir;
568
+ files.set(`${dir}/pyproject.toml`, generateSubPyproject(proj, pkg, importPkg));
549
569
  files.set(`${dir}/README.md`, generateSubReadme(proj, pkg));
550
570
  }
551
571
 
552
- files.set("pyproject.toml", generateRootPyproject(proj, distNames));
572
+ // The metapackage ships `<project>/__init__.py` (a thin styxkit re-export so
573
+ // `<project>.use_docker()` works, PEP 561 typed) into the same `<project>/`
574
+ // package the suites nest under. Its on-disk source dir must dodge a suite
575
+ // named after the project (both would write `<project>/`); the import name
576
+ // stays `<project>` via a `package-dir` remap so the namespace is preserved.
577
+ const metaImport = nsName ?? "project";
578
+ let metaDir = metaImport;
579
+ if (pkgDirs.includes(metaDir)) {
580
+ const collided = metaDir;
581
+ while (pkgDirs.includes(metaDir)) metaDir += "_";
582
+ warnings.push({
583
+ message: `metapackage directory "${collided}" collides with a suite directory; emitting its sources in "${metaDir}" instead (import name stays "${metaImport}")`,
584
+ });
585
+ }
586
+ files.set(`${metaDir}/__init__.py`, generateRootInitPy());
587
+ files.set(`${metaDir}/py.typed`, "");
588
+
589
+ files.set("pyproject.toml", generateRootPyproject(proj, distNames, metaImport, metaDir));
553
590
  files.set("README.md", generateRootReadme(proj, distNames));
554
591
  files.set("requirements.txt", generateRequirementsTxt(pkgDirs));
555
592
 
556
- return { files, errors: [], warnings: [] };
593
+ return { files, errors: [], warnings };
557
594
  }
558
595
  }
@@ -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;