@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.
- package/dist/index.cjs +7947 -0
- package/dist/index.d.cts +1143 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1143 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +7877 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +55 -0
- package/src/backend/backend.ts +95 -0
- package/src/backend/boutiques/boutiques.ts +1049 -0
- package/src/backend/boutiques/index.ts +1 -0
- package/src/backend/code-builder.ts +49 -0
- package/src/backend/collect-field-info.ts +50 -0
- package/src/backend/collect-named-types.ts +103 -0
- package/src/backend/collect-output-fields.ts +222 -0
- package/src/backend/find-doc.ts +38 -0
- package/src/backend/find-struct-node.ts +66 -0
- package/src/backend/index.ts +39 -0
- package/src/backend/python/arg-builder.ts +454 -0
- package/src/backend/python/emit.ts +638 -0
- package/src/backend/python/index.ts +9 -0
- package/src/backend/python/outputs-emit.ts +430 -0
- package/src/backend/python/packaging.ts +173 -0
- package/src/backend/python/python.ts +558 -0
- package/src/backend/python/snippet.ts +84 -0
- package/src/backend/python/typemap.ts +131 -0
- package/src/backend/python/types.ts +8 -0
- package/src/backend/python/validate-emit.ts +356 -0
- package/src/backend/resolve-field-binding.ts +41 -0
- package/src/backend/resolve-output-tokens.ts +80 -0
- package/src/backend/schema/index.ts +2 -0
- package/src/backend/schema/jsonschema.ts +303 -0
- package/src/backend/scope.ts +50 -0
- package/src/backend/sig-entries.ts +97 -0
- package/src/backend/snippet-core.ts +185 -0
- package/src/backend/string-case.ts +30 -0
- package/src/backend/styxdefs-compat.ts +21 -0
- package/src/backend/type-keys.ts +52 -0
- package/src/backend/typescript/arg-builder.ts +420 -0
- package/src/backend/typescript/emit.ts +450 -0
- package/src/backend/typescript/index.ts +10 -0
- package/src/backend/typescript/outputs-emit.ts +389 -0
- package/src/backend/typescript/packaging.ts +130 -0
- package/src/backend/typescript/snippet.ts +60 -0
- package/src/backend/typescript/typemap.ts +47 -0
- package/src/backend/typescript/types.ts +8 -0
- package/src/backend/typescript/typescript.ts +507 -0
- package/src/backend/typescript/validate-emit.ts +341 -0
- package/src/backend/union-variants.ts +42 -0
- package/src/backend/validate-walk.ts +111 -0
- package/src/bindings/binding.ts +77 -0
- package/src/bindings/format.ts +176 -0
- package/src/bindings/index.ts +16 -0
- package/src/bindings/output-gate.ts +50 -0
- package/src/bindings/resolved-output.ts +56 -0
- package/src/bindings/types.ts +16 -0
- package/src/frontend/argdump/index.ts +1 -0
- package/src/frontend/argdump/parser.ts +914 -0
- package/src/frontend/boutiques/destruct-template.ts +50 -0
- package/src/frontend/boutiques/index.ts +1 -0
- package/src/frontend/boutiques/parser.ts +676 -0
- package/src/frontend/boutiques/split-command.ts +69 -0
- package/src/frontend/detect-format.ts +42 -0
- package/src/frontend/frontend.ts +31 -0
- package/src/frontend/index.ts +9 -0
- package/src/frontend/workbench/index.ts +1 -0
- package/src/frontend/workbench/parser.ts +351 -0
- package/src/index.ts +41 -0
- package/src/ir/builders.ts +69 -0
- package/src/ir/format.ts +157 -0
- package/src/ir/index.ts +32 -0
- package/src/ir/meta.ts +91 -0
- package/src/ir/node.ts +95 -0
- package/src/ir/passes/canonicalize.ts +108 -0
- package/src/ir/passes/flatten.ts +73 -0
- package/src/ir/passes/index.ts +7 -0
- package/src/ir/passes/pass.ts +86 -0
- package/src/ir/passes/pipeline.ts +21 -0
- package/src/ir/passes/remove-empty.ts +76 -0
- package/src/ir/passes/simplify.ts +179 -0
- package/src/ir/types.ts +15 -0
- package/src/manifest/context.ts +36 -0
- package/src/manifest/index.ts +3 -0
- package/src/manifest/types.ts +15 -0
- package/src/solver/assign-access.ts +218 -0
- package/src/solver/index.ts +4 -0
- package/src/solver/resolve-outputs.ts +233 -0
- 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
|
+
}
|