@styx-api/core 0.1.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.
Files changed (88) hide show
  1. package/dist/index.cjs +7947 -0
  2. package/dist/index.d.cts +1143 -0
  3. package/dist/index.d.cts.map +1 -0
  4. package/dist/index.d.mts +1143 -0
  5. package/dist/index.d.mts.map +1 -0
  6. package/dist/index.mjs +7877 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/package.json +55 -0
  9. package/src/backend/backend.ts +95 -0
  10. package/src/backend/boutiques/boutiques.ts +1049 -0
  11. package/src/backend/boutiques/index.ts +1 -0
  12. package/src/backend/code-builder.ts +49 -0
  13. package/src/backend/collect-field-info.ts +50 -0
  14. package/src/backend/collect-named-types.ts +103 -0
  15. package/src/backend/collect-output-fields.ts +222 -0
  16. package/src/backend/find-doc.ts +38 -0
  17. package/src/backend/find-struct-node.ts +66 -0
  18. package/src/backend/index.ts +39 -0
  19. package/src/backend/python/arg-builder.ts +454 -0
  20. package/src/backend/python/emit.ts +638 -0
  21. package/src/backend/python/index.ts +9 -0
  22. package/src/backend/python/outputs-emit.ts +430 -0
  23. package/src/backend/python/packaging.ts +173 -0
  24. package/src/backend/python/python.ts +558 -0
  25. package/src/backend/python/snippet.ts +84 -0
  26. package/src/backend/python/typemap.ts +131 -0
  27. package/src/backend/python/types.ts +8 -0
  28. package/src/backend/python/validate-emit.ts +356 -0
  29. package/src/backend/resolve-field-binding.ts +41 -0
  30. package/src/backend/resolve-output-tokens.ts +80 -0
  31. package/src/backend/schema/index.ts +2 -0
  32. package/src/backend/schema/jsonschema.ts +303 -0
  33. package/src/backend/scope.ts +50 -0
  34. package/src/backend/sig-entries.ts +97 -0
  35. package/src/backend/snippet-core.ts +185 -0
  36. package/src/backend/string-case.ts +30 -0
  37. package/src/backend/styxdefs-compat.ts +21 -0
  38. package/src/backend/type-keys.ts +52 -0
  39. package/src/backend/typescript/arg-builder.ts +420 -0
  40. package/src/backend/typescript/emit.ts +450 -0
  41. package/src/backend/typescript/index.ts +10 -0
  42. package/src/backend/typescript/outputs-emit.ts +389 -0
  43. package/src/backend/typescript/packaging.ts +130 -0
  44. package/src/backend/typescript/snippet.ts +60 -0
  45. package/src/backend/typescript/typemap.ts +47 -0
  46. package/src/backend/typescript/types.ts +8 -0
  47. package/src/backend/typescript/typescript.ts +507 -0
  48. package/src/backend/typescript/validate-emit.ts +341 -0
  49. package/src/backend/union-variants.ts +42 -0
  50. package/src/backend/validate-walk.ts +111 -0
  51. package/src/bindings/binding.ts +77 -0
  52. package/src/bindings/format.ts +176 -0
  53. package/src/bindings/index.ts +16 -0
  54. package/src/bindings/output-gate.ts +50 -0
  55. package/src/bindings/resolved-output.ts +56 -0
  56. package/src/bindings/types.ts +16 -0
  57. package/src/frontend/argdump/index.ts +1 -0
  58. package/src/frontend/argdump/parser.ts +914 -0
  59. package/src/frontend/boutiques/destruct-template.ts +50 -0
  60. package/src/frontend/boutiques/index.ts +1 -0
  61. package/src/frontend/boutiques/parser.ts +676 -0
  62. package/src/frontend/boutiques/split-command.ts +69 -0
  63. package/src/frontend/detect-format.ts +42 -0
  64. package/src/frontend/frontend.ts +31 -0
  65. package/src/frontend/index.ts +9 -0
  66. package/src/frontend/workbench/index.ts +1 -0
  67. package/src/frontend/workbench/parser.ts +351 -0
  68. package/src/index.ts +41 -0
  69. package/src/ir/builders.ts +69 -0
  70. package/src/ir/format.ts +157 -0
  71. package/src/ir/index.ts +32 -0
  72. package/src/ir/meta.ts +91 -0
  73. package/src/ir/node.ts +95 -0
  74. package/src/ir/passes/canonicalize.ts +108 -0
  75. package/src/ir/passes/flatten.ts +73 -0
  76. package/src/ir/passes/index.ts +7 -0
  77. package/src/ir/passes/pass.ts +86 -0
  78. package/src/ir/passes/pipeline.ts +21 -0
  79. package/src/ir/passes/remove-empty.ts +76 -0
  80. package/src/ir/passes/simplify.ts +179 -0
  81. package/src/ir/types.ts +15 -0
  82. package/src/manifest/context.ts +36 -0
  83. package/src/manifest/index.ts +3 -0
  84. package/src/manifest/types.ts +15 -0
  85. package/src/solver/assign-access.ts +218 -0
  86. package/src/solver/index.ts +4 -0
  87. package/src/solver/resolve-outputs.ts +233 -0
  88. package/src/solver/solver.ts +319 -0
@@ -0,0 +1,558 @@
1
+ import type { BoundType } from "../../bindings/index.js";
2
+ import type { AppMeta } from "../../ir/index.js";
3
+ import type { CodegenContext, PackageMeta, ProjectMeta } from "../../manifest/index.js";
4
+ import type { AppEntrypoint, Backend, EmitResult, EmittedApp, EmittedPackage } from "../backend.js";
5
+ import type { SigEntry } from "../sig-entries.js";
6
+ import type { NamedType } from "./types.js";
7
+ import {
8
+ generateRequirementsTxt,
9
+ generateRootPyproject,
10
+ generateRootReadme,
11
+ generateSubPyproject,
12
+ generateSubReadme,
13
+ pyDistName,
14
+ } from "./packaging.js";
15
+ import { CodeBuilder } from "../code-builder.js";
16
+ import { Scope } from "../scope.js";
17
+ import { pascalCase, screamingSnakeCase, snakeCase } from "../string-case.js";
18
+ import { buildSigEntries } from "../sig-entries.js";
19
+ import {
20
+ emitBuildCargs,
21
+ emitImports,
22
+ emitKwargWrapper,
23
+ emitMetadata,
24
+ emitParamsFactory,
25
+ emitTypeDeclarations,
26
+ emitWrapperFunction,
27
+ pyScrubIdent,
28
+ pySigOptions,
29
+ } from "./emit.js";
30
+ import { collectFieldInfo } from "./types.js";
31
+ import { emitValidate } from "./validate-emit.js";
32
+ import {
33
+ emitBuildOutputs,
34
+ emitOutputsClass,
35
+ emitStripExtensionsHelper,
36
+ needsStripExtensionsHelper,
37
+ streamFieldIds,
38
+ } from "./outputs-emit.js";
39
+ import { mapType } from "./typemap.js";
40
+ import { collectNamedTypes, resolveTypeName, structKey, unionKey } from "./types.js";
41
+
42
+ // Python reserved words + commonly-shadowed built-ins. Used to avoid collisions
43
+ // when generating identifiers. Includes keywords, common stdlib builtins, and
44
+ // the styxdefs symbols we emit/import.
45
+ const PY_RESERVED: ReadonlySet<string> = new Set([
46
+ "False",
47
+ "None",
48
+ "True",
49
+ "and",
50
+ "as",
51
+ "assert",
52
+ "async",
53
+ "await",
54
+ "break",
55
+ "class",
56
+ "continue",
57
+ "def",
58
+ "del",
59
+ "elif",
60
+ "else",
61
+ "except",
62
+ "finally",
63
+ "for",
64
+ "from",
65
+ "global",
66
+ "if",
67
+ "import",
68
+ "in",
69
+ "is",
70
+ "lambda",
71
+ "nonlocal",
72
+ "not",
73
+ "or",
74
+ "pass",
75
+ "raise",
76
+ "return",
77
+ "try",
78
+ "while",
79
+ "with",
80
+ "yield",
81
+ // Common builtins to avoid shadowing.
82
+ "list",
83
+ "dict",
84
+ "tuple",
85
+ "set",
86
+ "int",
87
+ "float",
88
+ "str",
89
+ "bool",
90
+ "type",
91
+ "print",
92
+ "open",
93
+ "input",
94
+ "range",
95
+ "len",
96
+ "id",
97
+ "object",
98
+ "Exception",
99
+ "ValueError",
100
+ "TypeError",
101
+ // styxdefs symbols we emit/import.
102
+ "Runner",
103
+ "Execution",
104
+ "Metadata",
105
+ "InputPathType",
106
+ "OutputPathType",
107
+ "get_global_runner",
108
+ "dataclasses",
109
+ "typing",
110
+ ]);
111
+
112
+ /**
113
+ * Per-tool public symbol names. With the flat `fsl/bet.py` layout each tool
114
+ * file emits these names directly - there is no internal/public alias split.
115
+ */
116
+ export interface PublicNames {
117
+ params: string;
118
+ outputs: string;
119
+ metadata: string;
120
+ cargs: string;
121
+ outputsFn: string;
122
+ paramsFn: string;
123
+ execute: string;
124
+ validate: string;
125
+ wrapper: string;
126
+ }
127
+
128
+ /** Public-name scheme used by the Python backend. Exported so the CLI and tests can use it. */
129
+ export function computePublicNames(appId: string | undefined): PublicNames {
130
+ if (!appId) {
131
+ return {
132
+ params: "Params",
133
+ outputs: "Outputs",
134
+ metadata: "METADATA",
135
+ cargs: "cargs",
136
+ outputsFn: "outputs",
137
+ paramsFn: "build_params",
138
+ execute: "execute",
139
+ validate: "validate",
140
+ wrapper: "run",
141
+ };
142
+ }
143
+ // Pre-scrub digit-leading ids (e.g. `3dPFM`) so all derived case forms
144
+ // produce valid Python identifiers in a consistent case (matches v1's
145
+ // `V_3D_PFM_METADATA` / `v_3d_pfm` style instead of mixed `v_3D_PFM`).
146
+ const id = /^[0-9]/.test(appId) ? "v_" + appId : appId;
147
+ return {
148
+ params: pascalCase(id),
149
+ outputs: pascalCase(id) + "Outputs",
150
+ metadata: screamingSnakeCase(id) + "_METADATA",
151
+ cargs: snakeCase(id) + "_cargs",
152
+ outputsFn: snakeCase(id) + "_outputs",
153
+ paramsFn: snakeCase(id) + "_params",
154
+ execute: snakeCase(id) + "_execute",
155
+ validate: snakeCase(id) + "_validate",
156
+ wrapper: snakeCase(id),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * The fully-derived naming/typing model for one tool's Python emission. Computed
162
+ * once by `buildEmitModel` so the file emitter and the call-site snippet renderer
163
+ * share the exact same public names, scrubbed kwarg names, and root typing - the
164
+ * snippet must match the function the generated code actually exposes.
165
+ */
166
+ export interface PyEmitModel {
167
+ appId: string | undefined;
168
+ pkg: string | undefined;
169
+ names: {
170
+ params: string;
171
+ outputs: string;
172
+ metadata: string;
173
+ cargs: string;
174
+ outputsFn: string;
175
+ paramsFn: string;
176
+ execute: string;
177
+ validate: string;
178
+ wrapper: string;
179
+ };
180
+ rootType: BoundType;
181
+ rootIsStruct: boolean;
182
+ namedTypes: Map<string, string>;
183
+ typeDecls: NamedType[];
184
+ rootTypeTag: string | undefined;
185
+ paramsType: string;
186
+ sigEntries: SigEntry[];
187
+ }
188
+
189
+ /**
190
+ * Derive the public names, named-type declarations, root typing, and per-field
191
+ * signature entries for one tool. Mutates `scope` exactly as the emitter needs
192
+ * (the `reg` registrations and the `sigScope` child), so passing the same scope
193
+ * the emitter continues with keeps later local registrations consistent.
194
+ */
195
+ export function buildEmitModel(
196
+ ctx: CodegenContext,
197
+ scope: Scope = new Scope(PY_RESERVED),
198
+ ): PyEmitModel {
199
+ const appId = ctx.app?.id;
200
+ const pkg = ctx.package?.name;
201
+ const publicNames = computePublicNames(appId);
202
+
203
+ const rootBinding = ctx.resolve(ctx.expr);
204
+ const rootType: BoundType = rootBinding?.type ?? { kind: "struct", fields: {} };
205
+ // Only treat the root as struct-shaped when there's a real binding. A
206
+ // synthesized empty-struct fallback (no root binding) means the solver
207
+ // collapsed everything away, so the kwarg wrapper has nothing to wrap.
208
+ const rootIsStruct = rootBinding?.type.kind === "struct";
209
+
210
+ // Pre-reserve module-level public names so any IR-derived names colliding
211
+ // with them get suffix-bumped. `params` is intentionally NOT pre-reserved -
212
+ // `collectNamedTypes` claims it for the root struct just below. Each name
213
+ // is scrubbed through `pyScrubIdent` first since the case helpers happily
214
+ // pass through digit-leading app ids like `3dvolreg.afni`.
215
+ const reg = (name: string) => scope.add(pyScrubIdent(name, PY_RESERVED));
216
+ const names = {
217
+ params: pyScrubIdent(publicNames.params, PY_RESERVED),
218
+ outputs: reg(publicNames.outputs),
219
+ metadata: reg(publicNames.metadata),
220
+ cargs: reg(publicNames.cargs),
221
+ outputsFn: reg(publicNames.outputsFn),
222
+ paramsFn: rootIsStruct ? reg(publicNames.paramsFn) : "",
223
+ execute: rootIsStruct ? reg(publicNames.execute) : "",
224
+ validate: reg(publicNames.validate),
225
+ wrapper: reg(publicNames.wrapper),
226
+ };
227
+
228
+ // Prefix nested type names with the tool's root name so a suite's flat
229
+ // `from .x import *` re-exports don't shadow same-named types across tools.
230
+ const { namedTypes, typeDecls } = collectNamedTypes(
231
+ rootType,
232
+ names.params,
233
+ scope,
234
+ pascalCase,
235
+ appId ? names.params : "",
236
+ );
237
+
238
+ names.params =
239
+ (rootType.kind === "struct" ? namedTypes.get(structKey(rootType)) : undefined) ??
240
+ (rootType.kind === "union" ? namedTypes.get(unionKey(rootType)) : undefined) ??
241
+ names.params;
242
+
243
+ // Tag injected as `@type: Literal[...]` on the root TypedDict (and as a
244
+ // constant key by the params factory). Skipped when appId/pkg aren't known.
245
+ const rootTypeTag = appId && pkg ? `${pkg}/${appId}` : undefined;
246
+
247
+ const paramsType =
248
+ rootType.kind === "struct" || rootType.kind === "union"
249
+ ? names.params
250
+ : mapType(rootType, resolveTypeName(namedTypes));
251
+
252
+ // Build the per-field SigEntry list once - the factory and kwarg wrapper
253
+ // both consume it, so the host names registered here must satisfy both
254
+ // function scopes. Pre-reserve `params` (factory + wrapper body) and
255
+ // `runner` (wrapper signature) so a wire key matching either gets
256
+ // suffix-bumped. `rootType` is narrowed by `rootIsStruct` for the `Extract`
257
+ // constraint.
258
+ const sigScope = scope.child(["params", "runner"]);
259
+ const sigEntries =
260
+ rootIsStruct && rootType.kind === "struct"
261
+ ? buildSigEntries(
262
+ rootType,
263
+ collectFieldInfo(ctx, rootType),
264
+ (wireKey) => sigScope.add(pyScrubIdent(wireKey, PY_RESERVED)),
265
+ pySigOptions(resolveTypeName(namedTypes)),
266
+ )
267
+ : [];
268
+
269
+ return {
270
+ appId,
271
+ pkg,
272
+ names,
273
+ rootType,
274
+ rootIsStruct,
275
+ namedTypes,
276
+ typeDecls,
277
+ rootTypeTag,
278
+ paramsType,
279
+ sigEntries,
280
+ };
281
+ }
282
+
283
+ export function generatePython(ctx: CodegenContext, packageScope?: Scope): string {
284
+ const cb = new CodeBuilder(" ");
285
+ // A package-shared scope keeps top-level names unique across every tool in the
286
+ // suite's `from .x import *` re-exports; without one a per-tool scope suffices.
287
+ const scope = packageScope ?? new Scope(PY_RESERVED);
288
+
289
+ const {
290
+ names,
291
+ rootType,
292
+ rootIsStruct,
293
+ namedTypes,
294
+ typeDecls,
295
+ rootTypeTag,
296
+ paramsType,
297
+ sigEntries,
298
+ } = buildEmitModel(ctx, scope);
299
+
300
+ // Auto-generated header.
301
+ cb.comment("This file was auto generated by Styx.", "# ");
302
+ cb.comment("Do not edit this file directly.", "# ");
303
+ cb.blank();
304
+
305
+ // Every tool emits an Outputs object: at minimum the synthetic `root` output
306
+ // directory (output_file(".")), plus any declared file/mutable outputs and
307
+ // stdout/stderr stream fields. OutputPathType is therefore always imported.
308
+ const emitOutputs = true;
309
+
310
+ emitImports(cb, true);
311
+ cb.blank();
312
+
313
+ emitMetadata(ctx, names.metadata, cb);
314
+ cb.blank();
315
+
316
+ emitTypeDeclarations(typeDecls, namedTypes, ctx, cb, names.params, rootTypeTag);
317
+
318
+ if (emitOutputs) {
319
+ emitOutputsClass(ctx, names.outputs, cb);
320
+ cb.blank();
321
+ }
322
+
323
+ if (emitOutputs && needsStripExtensionsHelper(ctx)) {
324
+ emitStripExtensionsHelper(cb);
325
+ cb.blank();
326
+ }
327
+
328
+ // Params factory (struct-rooted tools only): a kwarg-style builder for the
329
+ // params dict. Useful for callers that want to build a dict to mutate before
330
+ // executing.
331
+ if (rootIsStruct) {
332
+ emitParamsFactory(sigEntries, names.paramsFn, paramsType, rootTypeTag, cb);
333
+ cb.blank();
334
+ }
335
+
336
+ // Validation: walks the root binding and raises StyxValidationError on bad
337
+ // input. Called first thing in the dict-style execute (below).
338
+ emitValidate(
339
+ ctx,
340
+ rootType,
341
+ ctx.expr,
342
+ paramsType,
343
+ names.validate,
344
+ resolveTypeName(namedTypes),
345
+ scope,
346
+ cb,
347
+ );
348
+ cb.blank();
349
+
350
+ emitBuildCargs(ctx, rootType, paramsType, names.cargs, cb);
351
+ cb.blank();
352
+
353
+ if (emitOutputs) {
354
+ emitBuildOutputs(ctx, paramsType, names.outputs, names.outputsFn, cb);
355
+ cb.blank();
356
+ }
357
+
358
+ // Dict-style execute function. For struct roots it's the internal
359
+ // `<tool>_execute`; for other roots it doubles as the user-facing wrapper.
360
+ const executeName = rootIsStruct ? names.execute : names.wrapper;
361
+ emitWrapperFunction(
362
+ ctx,
363
+ paramsType,
364
+ executeName,
365
+ names.metadata,
366
+ names.cargs,
367
+ emitOutputs ? names.outputsFn : undefined,
368
+ emitOutputs ? names.outputs : undefined,
369
+ names.validate,
370
+ streamFieldIds(ctx),
371
+ cb,
372
+ );
373
+ cb.blank();
374
+
375
+ // Kwarg-style wrapper (struct roots only): the v1-parity user-facing entry.
376
+ if (rootIsStruct) {
377
+ emitKwargWrapper(
378
+ ctx,
379
+ sigEntries,
380
+ names.wrapper,
381
+ names.paramsFn,
382
+ names.execute,
383
+ emitOutputs ? names.outputs : undefined,
384
+ cb,
385
+ );
386
+ cb.blank();
387
+ }
388
+
389
+ // `__all__` keeps suite-level `from .bet import *` from re-exporting the
390
+ // module's stdlib/styxdefs imports.
391
+ const publicSymbols = [
392
+ names.params,
393
+ ...(emitOutputs ? [names.outputs] : []),
394
+ names.metadata,
395
+ names.cargs,
396
+ ...(emitOutputs ? [names.outputsFn] : []),
397
+ ...(rootIsStruct ? [names.paramsFn, names.execute] : []),
398
+ names.validate,
399
+ names.wrapper,
400
+ ];
401
+ cb.line("__all__ = [");
402
+ cb.indent(() => {
403
+ for (const sym of publicSymbols) cb.line(`"${sym}",`);
404
+ });
405
+ cb.line("]");
406
+
407
+ return cb.toString();
408
+ }
409
+
410
+ /**
411
+ * Module name (file stem) for an app: snake_case of app.id, fallback `output`.
412
+ * Scrubbed so digit-leading app ids (e.g. `3dPFM` -> `v_3d_pfm`) and keyword
413
+ * collisions don't break `from .<mod> import *` in the package __init__.
414
+ */
415
+ export function appModuleName(meta: AppMeta | undefined): string {
416
+ if (!meta?.id) return "output";
417
+ return pyScrubIdent(snakeCase(meta.id), PY_RESERVED);
418
+ }
419
+
420
+ /**
421
+ * The dispatch entrypoint for one app: its root `@type` (`<package>/<app>`) and
422
+ * the dict-style execute function name. Returns undefined when the id or package
423
+ * is unknown (no stable `@type`), so the app is left out of the suite dispatcher.
424
+ */
425
+ export function appEntrypoint(ctx: CodegenContext): AppEntrypoint | undefined {
426
+ const appId = ctx.app?.id;
427
+ const pkg = ctx.package?.name;
428
+ if (!appId || !pkg) return undefined;
429
+ const publicNames = computePublicNames(appId);
430
+ const rootIsStruct = ctx.resolve(ctx.expr)?.type.kind === "struct";
431
+ const executeFn = pyScrubIdent(
432
+ rootIsStruct ? publicNames.execute : publicNames.wrapper,
433
+ PY_RESERVED,
434
+ );
435
+ return { type: `${pkg}/${appId}`, executeFn };
436
+ }
437
+
438
+ /**
439
+ * Generate the suite-level `__init__.py` re-export for a package containing
440
+ * multiple tool modules. Each tool module's public symbols are surfaced via
441
+ * `from .bet import *` (each tool file defines `__all__`). When apps carry a
442
+ * dispatch entrypoint, a suite-level `execute(params, runner)` is appended that
443
+ * routes a config object to the right tool by its root `@type`.
444
+ */
445
+ export function generatePackageInit(apps: EmittedApp[]): string {
446
+ const cb = new CodeBuilder(" ");
447
+ cb.comment("This file was auto generated by Styx.", "# ");
448
+ cb.comment("Do not edit this file directly.", "# ");
449
+ cb.blank();
450
+
451
+ const dispatch = apps
452
+ .map((a) => a.entrypoint)
453
+ .filter((e): e is AppEntrypoint => e !== undefined)
454
+ .sort((a, b) => a.type.localeCompare(b.type));
455
+
456
+ if (dispatch.length > 0) {
457
+ cb.line("import typing");
458
+ cb.blank();
459
+ cb.line("from styxdefs import Runner");
460
+ cb.blank();
461
+ }
462
+
463
+ const modules = apps
464
+ .map((a) => appModuleName(a.meta))
465
+ .filter((name): name is string => !!name)
466
+ .sort();
467
+
468
+ for (const mod of modules) {
469
+ cb.line(`from .${mod} import *`);
470
+ }
471
+
472
+ if (dispatch.length > 0) {
473
+ cb.blank();
474
+ emitPackageDispatch(cb, dispatch);
475
+ }
476
+
477
+ return cb.toString();
478
+ }
479
+
480
+ /** Emit the suite-level `execute(params, runner)` dispatcher over `@type`. */
481
+ function emitPackageDispatch(cb: CodeBuilder, dispatch: AppEntrypoint[]): void {
482
+ cb.line(
483
+ "def execute(params: dict[str, typing.Any], runner: Runner | None = None) -> typing.Any:",
484
+ );
485
+ cb.indent(() => {
486
+ cb.line('"""Run a tool in this package from a params object, routed by its `@type`."""');
487
+ cb.line("_dispatch: dict[str, typing.Callable[[typing.Any, Runner | None], typing.Any]] = {");
488
+ cb.indent(() => {
489
+ for (const e of dispatch) {
490
+ cb.line(`${JSON.stringify(e.type)}: ${e.executeFn},`);
491
+ }
492
+ });
493
+ cb.line("}");
494
+ cb.line('_fn = _dispatch.get(params["@type"])');
495
+ cb.line("if _fn is None:");
496
+ cb.indent(() => {
497
+ cb.line(`raise ValueError(f"No tool registered for @type {params['@type']!r}")`);
498
+ });
499
+ cb.line("return _fn(params, runner)");
500
+ });
501
+ }
502
+
503
+ export class PythonBackend implements Backend {
504
+ readonly name = "python";
505
+ readonly target = "python";
506
+
507
+ emitApp(ctx: CodegenContext, scope?: Scope): EmittedApp {
508
+ const code = generatePython(ctx, scope);
509
+ const fileName = `${appModuleName(ctx.app)}.py`;
510
+ return {
511
+ meta: ctx.app,
512
+ entrypoint: appEntrypoint(ctx),
513
+ files: new Map([[fileName, code]]),
514
+ errors: [],
515
+ warnings: [],
516
+ };
517
+ }
518
+
519
+ newPackageScope(): Scope {
520
+ return new Scope(PY_RESERVED);
521
+ }
522
+
523
+ emitPackage(pkg: PackageMeta, apps: EmittedApp[]): EmittedPackage {
524
+ return {
525
+ meta: pkg,
526
+ files: new Map([
527
+ ["__init__.py", generatePackageInit(apps)],
528
+ // PEP 561 marker so type-checkers treat the generated suite as typed.
529
+ ["py.typed", ""],
530
+ ]),
531
+ errors: [],
532
+ warnings: [],
533
+ };
534
+ }
535
+
536
+ emitProject(proj: ProjectMeta, packages: EmittedPackage[]): EmitResult {
537
+ const files = new Map<string, string>();
538
+ const distNames: string[] = [];
539
+ const pkgDirs: string[] = [];
540
+
541
+ for (const p of packages) {
542
+ const pkg = p.meta ?? {};
543
+ // Mirror the CLI's `pkgDir` fallback so a nameless package's source dir
544
+ // still gets a matching pyproject/README instead of being orphaned.
545
+ const dir = pkg.name ?? "package";
546
+ pkgDirs.push(dir);
547
+ distNames.push(pyDistName(proj, pkg));
548
+ files.set(`${dir}/pyproject.toml`, generateSubPyproject(proj, pkg));
549
+ files.set(`${dir}/README.md`, generateSubReadme(proj, pkg));
550
+ }
551
+
552
+ files.set("pyproject.toml", generateRootPyproject(proj, distNames));
553
+ files.set("README.md", generateRootReadme(proj, distNames));
554
+ files.set("requirements.txt", generateRequirementsTxt(pkgDirs));
555
+
556
+ return { files, errors: [], warnings: [] };
557
+ }
558
+ }
@@ -0,0 +1,84 @@
1
+ import type { BoundType } from "../../bindings/index.js";
2
+ import type { CodegenContext } from "../../manifest/index.js";
3
+ import type { SigEntry } from "../sig-entries.js";
4
+ import type { SnippetDialect, SnippetOptions } from "../snippet-core.js";
5
+ import { renderValue } from "../snippet-core.js";
6
+ import { buildEmitModel } from "./python.js";
7
+ import { pyStr } from "./typemap.js";
8
+
9
+ /** Snippet rendering hooks for Python (dict literals, `True`/`None`). */
10
+ const pyDialect: SnippetDialect = {
11
+ indentUnit: " ",
12
+ string: pyStr,
13
+ boolean: (b) => (b ? "True" : "False"),
14
+ number: (n) => (Number.isFinite(n) ? String(n) : "float('nan')"),
15
+ null: "None",
16
+ objKey: (k) => pyStr(k),
17
+ };
18
+
19
+ /**
20
+ * Render a Python call snippet for one tool from a config object (the params
21
+ * dict the form produces, keyed by Boutiques wire names).
22
+ *
23
+ * Struct-rooted tools use the ergonomic kwarg wrapper -
24
+ * `fsl.bet(infile=..., fractional_intensity=0.5)` - whose keyword names are the
25
+ * *scrubbed host* identifiers (`float` -> `float_`), not the wire keys; the
26
+ * per-field mapping comes from the same `buildSigEntries` the generated wrapper
27
+ * is built from, so the snippet matches the real signature. Nested structs /
28
+ * union variants / lists-of-structs have no constructor in the generated code,
29
+ * so they render as plain dict literals keyed by wire names.
30
+ *
31
+ * Union- (or otherwise non-struct-) rooted tools have no kwarg wrapper; the
32
+ * single dict-style `<tool>` entry is called with one object-literal argument.
33
+ *
34
+ * The snippet matches the *standalone* (single-descriptor) emission of the same
35
+ * context - which is how the hub compiles - not a catalog emission where a
36
+ * shared package scope could suffix-bump a name.
37
+ *
38
+ * @param ctx - The compiled context (compile -> pipeline -> solve ->
39
+ * resolveOutputs -> createContext, as in the CLI's `readAndCompile`).
40
+ * @param config - The params object, keyed by Boutiques *wire* names (not host
41
+ * identifiers). Every union-typed value - including the root of a union-rooted
42
+ * tool - must carry its `@type` discriminator so the variant can be matched;
43
+ * the root struct's `@type` is supplied by the renderer, so omit it there.
44
+ * @param opts - Import and package-root options.
45
+ */
46
+ export function renderPythonCall(
47
+ ctx: CodegenContext,
48
+ config: Record<string, unknown>,
49
+ opts: SnippetOptions = {},
50
+ ): string {
51
+ const model = buildEmitModel(ctx);
52
+ const pkg = ctx.package?.name;
53
+ const callee = pkg ? `${pkg}.${model.names.wrapper}` : model.names.wrapper;
54
+
55
+ const call =
56
+ model.rootIsStruct && model.rootType.kind === "struct"
57
+ ? renderKwargCall(callee, model.sigEntries, model.rootType, config)
58
+ : `${callee}(${renderValue(config, model.rootType, "", pyDialect)})`;
59
+
60
+ if (opts.includeImport === false || !pkg) return call;
61
+ const root = opts.packageRoot ?? ctx.project?.name;
62
+ const importLine = root ? `from ${root} import ${pkg}` : `import ${pkg}`;
63
+ return `${importLine}\n\n${call}`;
64
+ }
65
+
66
+ /** Render `callee(name=value, ...)` for a struct root using scrubbed kwarg names. */
67
+ function renderKwargCall(
68
+ callee: string,
69
+ sigEntries: readonly SigEntry[],
70
+ rootType: Extract<BoundType, { kind: "struct" }>,
71
+ config: Record<string, unknown>,
72
+ ): string {
73
+ const nameFor = new Map(sigEntries.map((e) => [e.wireKey, e.name]));
74
+ const indent = pyDialect.indentUnit;
75
+ const lines: string[] = [];
76
+ for (const [wireKey, fieldType] of Object.entries(rootType.fields)) {
77
+ if (fieldType.kind === "literal") continue; // @type / consts injected by the wrapper
78
+ if (!(wireKey in config)) continue;
79
+ const name = nameFor.get(wireKey) ?? wireKey;
80
+ lines.push(`${indent}${name}=${renderValue(config[wireKey], fieldType, indent, pyDialect)},`);
81
+ }
82
+ if (lines.length === 0) return `${callee}()`;
83
+ return `${callee}(\n${lines.join("\n")}\n)`;
84
+ }