@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,638 @@
1
+ import type { BoundType } from "../../bindings/index.js";
2
+ import type { CodegenContext } from "../../manifest/index.js";
3
+ import { CodeBuilder } from "../code-builder.js";
4
+ import type { SigEntry, SigOptions } from "../sig-entries.js";
5
+ import { snakeCase } from "../string-case.js";
6
+ import { structKey, unionKey } from "../type-keys.js";
7
+ import type { ArgResult } from "./arg-builder.js";
8
+ import { buildArgs, resultToStmt } from "./arg-builder.js";
9
+ import { mapType, pyStr, renderPyLiteral } from "./typemap.js";
10
+ import type { NamedType } from "./types.js";
11
+ import { collectFieldInfo, resolveTypeName } from "./types.js";
12
+
13
+ /**
14
+ * Emit a Python triple-quoted docstring. Single-line if short, multi-line for
15
+ * longer text. Should be placed as the first statement inside a function/class
16
+ * body or as a field doc immediately after the annotation.
17
+ */
18
+ export function emitDocstring(cb: CodeBuilder, text?: string): void {
19
+ if (!text) return;
20
+ const lines = text.split("\n");
21
+ if (lines.length === 1 && !lines[0]!.includes('"')) {
22
+ cb.line(`"""${lines[0]}"""`);
23
+ return;
24
+ }
25
+ cb.line(`"""`);
26
+ for (const line of lines) cb.line(line);
27
+ cb.line(`"""`);
28
+ }
29
+
30
+ export function emitImports(cb: CodeBuilder, emitOutputs: boolean): void {
31
+ cb.line("import dataclasses");
32
+ cb.line("import pathlib");
33
+ cb.line("import typing");
34
+ cb.blank();
35
+ const fromStyxdefs = ["Execution", "InputPathType", "Metadata", "Runner", "StyxValidationError"];
36
+ if (emitOutputs) fromStyxdefs.push("OutputPathType");
37
+ cb.line(`from styxdefs import ${fromStyxdefs.join(", ")}, get_global_runner`);
38
+ }
39
+
40
+ export function emitMetadata(ctx: CodegenContext, metaConst: string, cb: CodeBuilder): void {
41
+ const id = ctx.app?.id ?? "unknown";
42
+ const name = ctx.app?.doc?.title ?? ctx.app?.id ?? "unknown";
43
+ const pkg = ctx.package?.name ?? "unknown";
44
+
45
+ cb.line(`${metaConst} = Metadata(`);
46
+ cb.indent(() => {
47
+ cb.line(`id=${pyStr(id)},`);
48
+ cb.line(`name=${pyStr(name)},`);
49
+ cb.line(`package=${pyStr(pkg)},`);
50
+ if (ctx.app?.doc?.literature?.length) {
51
+ cb.line(`citations=[${ctx.app.doc.literature.map(pyStr).join(", ")}],`);
52
+ }
53
+ if (ctx.app?.container?.image) {
54
+ cb.line(`container_image_tag=${pyStr(ctx.app.container.image)},`);
55
+ }
56
+ });
57
+ cb.line(")");
58
+ }
59
+
60
+ /**
61
+ * Python keywords that would cause a SyntaxError or silent miscompile if used
62
+ * as a class-body annotation key (e.g. `lambda: float` parses as a lambda
63
+ * expression statement, not a TypedDict field). The class-syntax check uses
64
+ * this to force functional syntax for those fields. Builtins like `int`/`str`
65
+ * are NOT in this set - those are valid class attribute names.
66
+ */
67
+ export const PY_KEYWORDS = new Set([
68
+ "False",
69
+ "None",
70
+ "True",
71
+ "and",
72
+ "as",
73
+ "assert",
74
+ "async",
75
+ "await",
76
+ "break",
77
+ "class",
78
+ "continue",
79
+ "def",
80
+ "del",
81
+ "elif",
82
+ "else",
83
+ "except",
84
+ "finally",
85
+ "for",
86
+ "from",
87
+ "global",
88
+ "if",
89
+ "import",
90
+ "in",
91
+ "is",
92
+ "lambda",
93
+ "nonlocal",
94
+ "not",
95
+ "or",
96
+ "pass",
97
+ "raise",
98
+ "return",
99
+ "try",
100
+ "while",
101
+ "with",
102
+ "yield",
103
+ ]);
104
+
105
+ /** Can `s` be used as a class-attribute name in a TypedDict class body? */
106
+ function isPyIdent(s: string): boolean {
107
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(s) && !PY_KEYWORDS.has(s);
108
+ }
109
+
110
+ /**
111
+ * Emit the python source for one struct as a TypedDict. Uses functional syntax
112
+ * if any field name is not a Python identifier (e.g. `@type` discriminators);
113
+ * otherwise uses class syntax for readability.
114
+ *
115
+ * When `injectAtTypeTag` is given, an `@type: typing.Literal[<tag>]` entry is
116
+ * prepended; used by the root struct of single-tool params, whose tag is
117
+ * derived from `pkg/appId` rather than from the IR.
118
+ */
119
+ function emitStructTypedDict(
120
+ name: string,
121
+ type: Extract<BoundType, { kind: "struct" }>,
122
+ ctx: CodegenContext,
123
+ resolve: (t: BoundType) => string | undefined,
124
+ cb: CodeBuilder,
125
+ injectAtTypeTag?: string,
126
+ ): void {
127
+ const fieldInfo = collectFieldInfo(ctx, type);
128
+ const entries = Object.entries(type.fields);
129
+ // @type literal fields are special: they're not user-provided regular fields
130
+ // but discriminator values. Other literals are skipped (they have no runtime
131
+ // representation in the dict).
132
+ const hasInjectedAtType = injectAtTypeTag !== undefined;
133
+ const hasNonIdentKey =
134
+ hasInjectedAtType ||
135
+ entries.some(([k, v]) => {
136
+ if (v.kind === "literal") return k === "@type";
137
+ return !isPyIdent(k);
138
+ });
139
+
140
+ // Compute the typed entry list (skipping non-discriminator literals).
141
+ const typedEntries: Array<{ key: string; type: string; doc?: string }> = [];
142
+ if (injectAtTypeTag !== undefined) {
143
+ // NotRequired: the factory always sets @type and runtime dispatch tables
144
+ // read it, but callers building a dict by hand shouldn't have to type it.
145
+ // (Union variants further down keep their @type required - that one IS
146
+ // load-bearing for discriminated-union narrowing.)
147
+ typedEntries.push({
148
+ key: "@type",
149
+ type: `typing.NotRequired[typing.Literal[${pyStr(injectAtTypeTag)}]]`,
150
+ });
151
+ }
152
+ for (const [fieldName, fieldType] of entries) {
153
+ if (fieldType.kind === "literal") {
154
+ if (fieldName === "@type") {
155
+ const lit =
156
+ typeof fieldType.value === "string"
157
+ ? `typing.Literal[${pyStr(fieldType.value)}]`
158
+ : `typing.Literal[${fieldType.value}]`;
159
+ typedEntries.push({ key: fieldName, type: lit });
160
+ }
161
+ continue;
162
+ }
163
+ const fi = fieldInfo.get(fieldName);
164
+ const hasDefault = fi?.defaultValue !== undefined;
165
+ const isOptional = fieldType.kind === "optional";
166
+ // The value type is the inner type, never `| None`: the solver has no
167
+ // nullable, so a present value is never None. Omittability (the key may be
168
+ // absent) is expressed structurally via `typing.NotRequired[...]`.
169
+ const inner = isOptional ? fieldType.inner : fieldType;
170
+ let typeExpr = mapType(inner, resolve);
171
+ // A field is omittable iff it is `optional` or it carries a default (which
172
+ // includes flags - `defaultValue` false). Mark omittable fields NotRequired:
173
+ // optional-without-default fields are conditionally set by the factory, and
174
+ // defaulted fields may legitimately be absent in a hand-authored config (the
175
+ // absence-safe runtime reads apply the default). Required-without-default
176
+ // fields stay bare - the factory always writes them.
177
+ if (isOptional || hasDefault) {
178
+ typeExpr = `typing.NotRequired[${typeExpr}]`;
179
+ }
180
+ typedEntries.push({ key: fieldName, type: typeExpr, doc: fi?.doc });
181
+ }
182
+
183
+ if (hasNonIdentKey) {
184
+ cb.line(`${name} = typing.TypedDict(`);
185
+ cb.indent(() => {
186
+ cb.line(`${pyStr(name)},`);
187
+ cb.line(`{`);
188
+ cb.indent(() => {
189
+ for (const { key, type } of typedEntries) {
190
+ cb.line(`${pyStr(key)}: ${type},`);
191
+ }
192
+ });
193
+ cb.line(`},`);
194
+ });
195
+ cb.line(`)`);
196
+ } else {
197
+ cb.line(`class ${name}(typing.TypedDict):`);
198
+ cb.indent(() => {
199
+ if (typedEntries.length === 0) {
200
+ cb.line("pass");
201
+ return;
202
+ }
203
+ for (const { key, type, doc } of typedEntries) {
204
+ cb.line(`${key}: ${type}`);
205
+ if (doc) emitDocstring(cb, doc);
206
+ }
207
+ });
208
+ }
209
+ }
210
+
211
+ /** Structural identity key for a NamedType (only structs/unions are collected). */
212
+ function declKey(type: BoundType): string | undefined {
213
+ if (type.kind === "struct") return structKey(type);
214
+ if (type.kind === "union") return unionKey(type);
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * Collect the keys of every named struct/union directly referenced by `type`'s
220
+ * emitted expression. Wrappers (optional/list) are transparent; a nested
221
+ * struct/union is its own declaration, so we record its key and stop. This is
222
+ * the dependency edge set used to order declarations.
223
+ */
224
+ function collectRefs(type: BoundType, namedTypes: Map<string, string>, out: Set<string>): void {
225
+ switch (type.kind) {
226
+ case "optional":
227
+ collectRefs(type.inner, namedTypes, out);
228
+ break;
229
+ case "list":
230
+ collectRefs(type.item, namedTypes, out);
231
+ break;
232
+ case "struct": {
233
+ const k = structKey(type);
234
+ if (namedTypes.has(k)) out.add(k);
235
+ break;
236
+ }
237
+ case "union": {
238
+ const k = unionKey(type);
239
+ if (namedTypes.has(k)) out.add(k);
240
+ break;
241
+ }
242
+ default:
243
+ break;
244
+ }
245
+ }
246
+
247
+ /** Direct named-type dependencies of one declaration (its field/variant types). */
248
+ function declDeps(type: BoundType, namedTypes: Map<string, string>): Set<string> {
249
+ const out = new Set<string>();
250
+ if (type.kind === "struct") {
251
+ for (const fieldType of Object.values(type.fields)) collectRefs(fieldType, namedTypes, out);
252
+ } else if (type.kind === "union") {
253
+ for (const v of type.variants) collectRefs(v.type, namedTypes, out);
254
+ }
255
+ return out;
256
+ }
257
+
258
+ export function emitTypeDeclarations(
259
+ typeDecls: NamedType[],
260
+ namedTypes: Map<string, string>,
261
+ ctx: CodegenContext,
262
+ cb: CodeBuilder,
263
+ rootName?: string,
264
+ rootTypeTag?: string,
265
+ ): void {
266
+ const resolve = resolveTypeName(namedTypes);
267
+
268
+ // Python evaluates type expressions eagerly (no hoisting like TS): a name
269
+ // must be defined before any declaration references it. The collector yields
270
+ // types in forward-discovery order (parents before children); the old
271
+ // emission just reversed that, but reverse-discovery breaks for shared types
272
+ // in a DAG - e.g. a union arm discovered deep under the FIRST variant that is
273
+ // also referenced by a LATER sibling arm ends up emitted after its user.
274
+ // Instead, do a real topological sort over the dependency graph (post-order
275
+ // DFS so a type is emitted only after every type it references). Back-edges
276
+ // from a cycle are ignored - the recursion guard breaks them, and recursive
277
+ // descriptor types don't occur in practice.
278
+ const byKey = new Map<string, NamedType>();
279
+ for (const decl of typeDecls) {
280
+ const k = declKey(decl.type);
281
+ if (k !== undefined) byKey.set(k, decl);
282
+ }
283
+
284
+ const ordered: NamedType[] = [];
285
+ const visited = new Set<string>();
286
+ const onStack = new Set<string>();
287
+ function emitInOrder(key: string): void {
288
+ if (visited.has(key)) return;
289
+ const decl = byKey.get(key);
290
+ if (decl === undefined) return;
291
+ onStack.add(key);
292
+ for (const dep of declDeps(decl.type, namedTypes)) {
293
+ if (!onStack.has(dep)) emitInOrder(dep);
294
+ }
295
+ onStack.delete(key);
296
+ visited.add(key);
297
+ ordered.push(decl);
298
+ }
299
+ // Drive from the original discovery order so independent declarations keep a
300
+ // stable, deterministic relative order.
301
+ for (const decl of typeDecls) {
302
+ const k = declKey(decl.type);
303
+ if (k !== undefined) emitInOrder(k);
304
+ }
305
+
306
+ for (const { name, type } of ordered) {
307
+ if (type.kind === "struct") {
308
+ const inject = name === rootName ? rootTypeTag : undefined;
309
+ emitStructTypedDict(name, type, ctx, resolve, cb, inject);
310
+ cb.blank();
311
+ } else if (type.kind === "union") {
312
+ const parts = type.variants.map((v) => mapType(v.type, resolve));
313
+ cb.line(`${name} = ${parts.join(" | ")}`);
314
+ cb.blank();
315
+ }
316
+ }
317
+ }
318
+
319
+ export function emitBuildCargs(
320
+ ctx: CodegenContext,
321
+ rootType: BoundType,
322
+ paramsType: string,
323
+ funcName: string,
324
+ cb: CodeBuilder,
325
+ ): void {
326
+ let result: ArgResult;
327
+ try {
328
+ result = buildArgs(ctx.expr, ctx, rootType);
329
+ } catch {
330
+ cb.line(`def ${funcName}(params: ${paramsType}, execution: Execution) -> list[str]:`);
331
+ cb.indent(() => {
332
+ emitDocstring(cb, "Build command-line arguments from parameters.");
333
+ cb.line("return []");
334
+ });
335
+ return;
336
+ }
337
+
338
+ const argsCode = resultToStmt(result);
339
+
340
+ cb.line(`def ${funcName}(params: ${paramsType}, execution: Execution) -> list[str]:`);
341
+ cb.indent(() => {
342
+ emitDocstring(cb, "Build command-line arguments from parameters.");
343
+ cb.line("cargs: list[str] = []");
344
+ for (const line of argsCode.split("\n")) {
345
+ if (line.trim()) cb.line(line);
346
+ }
347
+ cb.line("return cargs");
348
+ });
349
+ }
350
+
351
+ export function emitWrapperFunction(
352
+ ctx: CodegenContext,
353
+ paramsType: string,
354
+ funcName: string,
355
+ metaConst: string,
356
+ cargsFunc: string,
357
+ outputsFunc: string | undefined,
358
+ outputsType: string | undefined,
359
+ validateFunc: string | undefined,
360
+ streams: { stdout?: string; stderr?: string },
361
+ cb: CodeBuilder,
362
+ ): void {
363
+ const emitOutputs = outputsFunc !== undefined;
364
+ const appDoc = ctx.app?.doc;
365
+ const returnType = emitOutputs && outputsType ? outputsType : "None";
366
+
367
+ cb.line(`def ${funcName}(params: ${paramsType}, runner: Runner | None = None) -> ${returnType}:`);
368
+ cb.indent(() => {
369
+ cb.line('"""');
370
+ let hasContent = false;
371
+ if (appDoc?.title) {
372
+ cb.line(appDoc.title);
373
+ hasContent = true;
374
+ }
375
+ if (appDoc?.description) {
376
+ if (hasContent) cb.blank();
377
+ cb.line(appDoc.description);
378
+ hasContent = true;
379
+ }
380
+ if (appDoc?.authors?.length) {
381
+ if (hasContent) cb.blank();
382
+ cb.line(`Author: ${appDoc.authors.join(", ")}`);
383
+ hasContent = true;
384
+ }
385
+ if (appDoc?.urls?.length) {
386
+ if (hasContent) cb.blank();
387
+ cb.line(`URL: ${appDoc.urls[0]}`);
388
+ hasContent = true;
389
+ }
390
+ if (hasContent) cb.blank();
391
+ cb.line("Args:");
392
+ cb.line(" params: The parameters.");
393
+ cb.line(" runner: Command runner (defaults to global runner).");
394
+ cb.blank();
395
+ cb.line("Returns:");
396
+ cb.line(emitOutputs ? " Tool outputs (paths to files produced by the tool)." : " None.");
397
+ cb.line('"""');
398
+ // Validate the params dict first (the kwarg wrapper delegates here, so it
399
+ // gets validation transitively; the statically-typed kwargs don't need it).
400
+ if (validateFunc) cb.line(`${validateFunc}(params)`);
401
+ cb.line("runner = runner if runner is not None else get_global_runner()");
402
+ cb.line(`execution = runner.start_execution(${metaConst})`);
403
+ cb.line("execution.params(params)");
404
+ // Local names `args`/`out` avoid colliding with the module-level `cargs`
405
+ // and `outputs` functions when they share generic names.
406
+ cb.line(`args = ${cargsFunc}(params, execution)`);
407
+ if (emitOutputs) {
408
+ cb.line(`out = ${outputsFunc}(params, execution)`);
409
+ const handlers: string[] = [];
410
+ if (streams.stdout) handlers.push(`handle_stdout=lambda s: out.${streams.stdout}.append(s)`);
411
+ if (streams.stderr) handlers.push(`handle_stderr=lambda s: out.${streams.stderr}.append(s)`);
412
+ cb.line(`execution.run(args${handlers.length ? ", " + handlers.join(", ") : ""})`);
413
+ cb.line("return out");
414
+ } else {
415
+ cb.line("execution.run(args)");
416
+ }
417
+ });
418
+ }
419
+
420
+ /** Convenience: derive a snake_case function name from the app id. */
421
+ export function appFuncName(ctx: CodegenContext, fallback: string): string {
422
+ return snakeCase(ctx.app?.id ?? fallback);
423
+ }
424
+
425
+ /**
426
+ * SigOptions hooks for Python. The kwarg-signature sentinel for an optional
427
+ * param is `T | None = None` - here `| None` is the *parameter* type (the
428
+ * "not provided" sentinel a caller passes), not the dict field type, which is
429
+ * just `typing.NotRequired[T]`. Keeping the sentinel preserves the ergonomic
430
+ * `foo(x)` call where omitted optionals default to `None` and the factory then
431
+ * drops them.
432
+ */
433
+ export function pySigOptions(resolve: (t: BoundType) => string | undefined): SigOptions {
434
+ return {
435
+ renderType: (t) => mapType(t, resolve),
436
+ nullableSuffix: " | None",
437
+ nullableDefault: "None",
438
+ renderDefault: renderPyLiteral,
439
+ };
440
+ }
441
+
442
+ /**
443
+ * Scrub a Boutiques wire name into a valid Python host identifier. Replaces
444
+ * any non-`[A-Za-z0-9_]` character with `_`; prefixes `v_` if the result
445
+ * starts with a digit; appends a single trailing underscore when the scrubbed
446
+ * name matches a reserved word or shadowed built-in (matching v1 niwrap's
447
+ * `float_:` style). The caller is responsible for further deduping the result
448
+ * through a `Scope` so collisions with already-registered locals don't slip
449
+ * through.
450
+ */
451
+ export function pyScrubIdent(name: string, reserved: ReadonlySet<string>): string {
452
+ let scrubbed = name.replace(/[^A-Za-z0-9_]/g, "_");
453
+ if (/^[0-9]/.test(scrubbed)) scrubbed = "v_" + scrubbed;
454
+ if (scrubbed === "") scrubbed = "_";
455
+ if (reserved.has(scrubbed)) scrubbed = scrubbed + "_";
456
+ return scrubbed;
457
+ }
458
+
459
+ /** Emit a sequence of `name: type [= default],` lines (one per entry) into `cb`. */
460
+ function emitSigParams(entries: readonly SigEntry[], cb: CodeBuilder): void {
461
+ for (const e of entries) {
462
+ if (e.sigDefault !== undefined) {
463
+ cb.line(`${e.name}: ${e.sigType} = ${e.sigDefault},`);
464
+ } else {
465
+ cb.line(`${e.name}: ${e.sigType},`);
466
+ }
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Word-wrap a Google-style "Args:" entry. Produces lines like
472
+ * `name: description that continues...\`
473
+ * ` until it ends here.`
474
+ * The first line is prefixed with `<indent><name>: `; continuations use
475
+ * `<indent> ` (4 spaces deeper). Lines that exceed `lineWidth` end with a
476
+ * `\` continuation marker.
477
+ */
478
+ function wrapDocEntry(name: string, doc: string, indent: string, lineWidth = 80): string[] {
479
+ const firstPrefix = `${indent}${name}: `;
480
+ const contPrefix = `${indent} `;
481
+ const words = doc.split(/\s+/).filter((w) => w.length > 0);
482
+ if (words.length === 0) return [`${firstPrefix.trimEnd()}`];
483
+
484
+ const lines: string[] = [];
485
+ let current = firstPrefix + words[0]!;
486
+ for (let i = 1; i < words.length; i++) {
487
+ const word = words[i]!;
488
+ if (current.length + 1 + word.length + 1 > lineWidth) {
489
+ lines.push(current + "\\");
490
+ current = contPrefix + word;
491
+ } else {
492
+ current += " " + word;
493
+ }
494
+ }
495
+ lines.push(current);
496
+ return lines;
497
+ }
498
+
499
+ /** Emit the per-field `Args:` block for a docstring. Caller is already at the
500
+ * correct indent (i.e. inside the function body). */
501
+ function emitArgsBlock(entries: readonly { name: string; doc?: string }[], cb: CodeBuilder): void {
502
+ cb.line("Args:");
503
+ for (const e of entries) {
504
+ for (const ln of wrapDocEntry(e.name, e.doc ?? "", " ")) cb.line(ln);
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Emit the `_params(...)` factory: a kwarg-style function that builds and
510
+ * returns the params dict (with `@type` injected). Non-optional fields (required,
511
+ * and defaulted scalars whose default lives on the signature) are always set in
512
+ * the literal. Every optional field is set conditionally when not None -
513
+ * including optional-with-default ones: their value type is `T | None` (the omit
514
+ * sentinel) but the dict field is the non-None `NotRequired[T]`, so a bare
515
+ * literal assignment of a possibly-None value would not type-check. When the
516
+ * caller omits the arg, the signature default (a concrete non-None value) flows
517
+ * through the guard and is written.
518
+ */
519
+ export function emitParamsFactory(
520
+ entries: readonly SigEntry[],
521
+ funcName: string,
522
+ paramsType: string,
523
+ typeTag: string | undefined,
524
+ cb: CodeBuilder,
525
+ ): void {
526
+ // Signature
527
+ if (entries.length === 0) {
528
+ cb.line(`def ${funcName}() -> ${paramsType}:`);
529
+ } else {
530
+ cb.line(`def ${funcName}(`);
531
+ cb.indent(() => emitSigParams(entries, cb));
532
+ cb.line(`) -> ${paramsType}:`);
533
+ }
534
+
535
+ cb.indent(() => {
536
+ // Docstring
537
+ cb.line('"""');
538
+ cb.line("Build parameters.");
539
+ if (entries.length > 0) {
540
+ cb.blank();
541
+ emitArgsBlock(entries, cb);
542
+ }
543
+ cb.blank();
544
+ cb.line("Returns:");
545
+ cb.line(" Parameter dictionary.");
546
+ cb.line('"""');
547
+
548
+ // Build dict: required and explicitly-defaulted fields go into the literal
549
+ cb.line(`params: ${paramsType} = {`);
550
+ cb.indent(() => {
551
+ if (typeTag !== undefined) cb.line(`"@type": ${pyStr(typeTag)},`);
552
+ for (const e of entries) {
553
+ if (!e.isOptional) {
554
+ cb.line(`${pyStr(e.wireKey)}: ${e.name},`);
555
+ }
556
+ }
557
+ });
558
+ cb.line("}");
559
+
560
+ // Conditional include for every optional field (its kwarg default supplies a
561
+ // non-None value when the caller omits the arg).
562
+ for (const e of entries) {
563
+ if (e.isOptional) {
564
+ cb.line(`if ${e.name} is not None:`);
565
+ cb.indent(() => cb.line(`params[${pyStr(e.wireKey)}] = ${e.name}`));
566
+ }
567
+ }
568
+ cb.line("return params");
569
+ });
570
+ }
571
+
572
+ /**
573
+ * Emit the user-facing kwarg wrapper: takes the same kwargs as `_params()`
574
+ * plus `runner`, builds the params dict, and delegates to the dict-style
575
+ * execute function.
576
+ */
577
+ export function emitKwargWrapper(
578
+ ctx: CodegenContext,
579
+ entries: readonly SigEntry[],
580
+ funcName: string,
581
+ paramsFnName: string,
582
+ executeFnName: string,
583
+ outputsType: string | undefined,
584
+ cb: CodeBuilder,
585
+ ): void {
586
+ // Signature: same as params factory + `runner` last
587
+ cb.line(`def ${funcName}(`);
588
+ cb.indent(() => {
589
+ emitSigParams(entries, cb);
590
+ cb.line("runner: Runner | None = None,");
591
+ });
592
+ const returnType = outputsType ?? "None";
593
+ cb.line(`) -> ${returnType}:`);
594
+
595
+ cb.indent(() => {
596
+ // Docstring: app title/description + per-field docs + runner + Returns.
597
+ const appDoc = ctx.app?.doc;
598
+ cb.line('"""');
599
+ if (appDoc?.title) cb.line(appDoc.title);
600
+ if (appDoc?.description) {
601
+ if (appDoc?.title) cb.blank();
602
+ cb.line(appDoc.description);
603
+ }
604
+ if (appDoc?.authors?.length) {
605
+ cb.blank();
606
+ cb.line(`Author: ${appDoc.authors.join(", ")}`);
607
+ }
608
+ if (appDoc?.urls?.length) {
609
+ cb.blank();
610
+ cb.line(`URL: ${appDoc.urls[0]}`);
611
+ }
612
+ cb.blank();
613
+ emitArgsBlock(
614
+ [...entries, { name: "runner", doc: "Command runner (defaults to global runner)." }],
615
+ cb,
616
+ );
617
+ cb.blank();
618
+ cb.line("Returns:");
619
+ cb.line(outputsType ? " Tool outputs (paths to files produced by the tool)." : " None.");
620
+ cb.line('"""');
621
+
622
+ // Body: delegate to factory + execute
623
+ if (entries.length === 0) {
624
+ cb.line(`params = ${paramsFnName}()`);
625
+ } else {
626
+ cb.line(`params = ${paramsFnName}(`);
627
+ cb.indent(() => {
628
+ for (const e of entries) cb.line(`${e.name}=${e.name},`);
629
+ });
630
+ cb.line(")");
631
+ }
632
+ if (outputsType) {
633
+ cb.line(`return ${executeFnName}(params, runner)`);
634
+ } else {
635
+ cb.line(`${executeFnName}(params, runner)`);
636
+ }
637
+ });
638
+ }
@@ -0,0 +1,9 @@
1
+ export type { PublicNames } from "./python.js";
2
+ export {
3
+ appModuleName,
4
+ computePublicNames,
5
+ generatePackageInit,
6
+ generatePython,
7
+ PythonBackend,
8
+ } from "./python.js";
9
+ export { renderPythonCall } from "./snippet.js";