@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,430 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Binding,
|
|
3
|
+
BindingId,
|
|
4
|
+
BoundType,
|
|
5
|
+
GateAtom,
|
|
6
|
+
ResolvedToken,
|
|
7
|
+
} from "../../bindings/index.js";
|
|
8
|
+
import { outputGate } from "../../bindings/index.js";
|
|
9
|
+
import { collectFieldInfo } from "../collect-field-info.js";
|
|
10
|
+
import type { CodegenContext } from "../../manifest/index.js";
|
|
11
|
+
import { CodeBuilder } from "../code-builder.js";
|
|
12
|
+
import {
|
|
13
|
+
type EmittedOutput,
|
|
14
|
+
type OutputShape,
|
|
15
|
+
collectMutableOutputs,
|
|
16
|
+
collectOutputFields,
|
|
17
|
+
rootOutput,
|
|
18
|
+
streamFields,
|
|
19
|
+
} from "../collect-output-fields.js";
|
|
20
|
+
import { PY_KEYWORDS, emitDocstring } from "./emit.js";
|
|
21
|
+
import { pyStr, renderAccess, renderPyLiteral } from "./typemap.js";
|
|
22
|
+
|
|
23
|
+
// The output-field/stream/mutable collection is language-agnostic and shared
|
|
24
|
+
// with the TypeScript and JSON Schema backends; re-export the predicates the
|
|
25
|
+
// backend entry point consumes so its import surface stays `./outputs-emit.js`.
|
|
26
|
+
export { hasAnyOutputs, hasMutableInputs, hasStreamOutputs } from "../collect-output-fields.js";
|
|
27
|
+
|
|
28
|
+
function outputTypeExpr(shape: OutputShape): string {
|
|
29
|
+
if (shape.kind === "list") return "list[OutputPathType]";
|
|
30
|
+
return shape.optional ? "OutputPathType | None" : "OutputPathType";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Field ids for stdout/stderr (in declaration order), for wrapper wiring. */
|
|
34
|
+
export function streamFieldIds(ctx: CodegenContext): { stdout?: string; stderr?: string } {
|
|
35
|
+
const fields = streamFields(ctx, pyId);
|
|
36
|
+
const res: { stdout?: string; stderr?: string } = {};
|
|
37
|
+
let idx = 0;
|
|
38
|
+
if (ctx.app?.stdout) res.stdout = fields[idx++]!.id;
|
|
39
|
+
if (ctx.app?.stderr) res.stderr = fields[idx++]!.id;
|
|
40
|
+
return res;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Emit `@dataclasses.dataclass\nclass <outputsType>:` declaration. */
|
|
44
|
+
export function emitOutputsClass(ctx: CodegenContext, outputsType: string, cb: CodeBuilder): void {
|
|
45
|
+
cb.line("@dataclasses.dataclass");
|
|
46
|
+
cb.line(`class ${outputsType}:`);
|
|
47
|
+
cb.indent(() => {
|
|
48
|
+
emitDocstring(cb, "Output paths produced by the tool.");
|
|
49
|
+
const fields = collectOutputFields(ctx, pyId);
|
|
50
|
+
const streams = streamFields(ctx, pyId);
|
|
51
|
+
if (fields.length === 0 && streams.length === 0) {
|
|
52
|
+
cb.line("pass");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for (const field of fields) {
|
|
56
|
+
cb.line(`${field.id}: ${outputTypeExpr(field.shape)}`);
|
|
57
|
+
if (field.doc) emitDocstring(cb, field.doc);
|
|
58
|
+
}
|
|
59
|
+
for (const s of streams) {
|
|
60
|
+
cb.line(`${s.id}: list[str]`);
|
|
61
|
+
if (s.doc) emitDocstring(cb, s.doc);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Substitutions for ref access while inside an iteration loop, and how `iter`
|
|
68
|
+
* segments in a binding's access path are resolved at emit time.
|
|
69
|
+
*/
|
|
70
|
+
type IterScope = Map<BindingId, string>;
|
|
71
|
+
|
|
72
|
+
interface OutputEmitCtx {
|
|
73
|
+
ctx: CodegenContext;
|
|
74
|
+
iter: IterScope;
|
|
75
|
+
/**
|
|
76
|
+
* Prefix substitutions for optional fields narrowed by an enclosing presence
|
|
77
|
+
* gate: maps a rendered access prefix to the `.get()`-narrowed local holding
|
|
78
|
+
* it. Threaded into `renderAccess` so reads use the local (one lookup, absent-
|
|
79
|
+
* safe, mypy-narrowable) - mirrors the cargs builder's `valueSubst`.
|
|
80
|
+
*/
|
|
81
|
+
subst: ReadonlyMap<string, string>;
|
|
82
|
+
/**
|
|
83
|
+
* Rendered Python default literals for root-level defaulted fields, keyed by
|
|
84
|
+
* field name. An output path that interpolates such a field (e.g. an output
|
|
85
|
+
* basename `maskfile`) reads it via `.get(key, <default>)` so an absent key
|
|
86
|
+
* substitutes the default rather than raising `KeyError`. Mirrors the cargs
|
|
87
|
+
* builder's `defaults`.
|
|
88
|
+
*/
|
|
89
|
+
defaults: ReadonlyMap<string, string>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The rendered default for a binding iff it is a root-level defaulted field. */
|
|
93
|
+
function rootFieldDefault(
|
|
94
|
+
binding: Binding | undefined,
|
|
95
|
+
defaults: ReadonlyMap<string, string>,
|
|
96
|
+
): string | undefined {
|
|
97
|
+
if (!binding) return undefined;
|
|
98
|
+
const a = binding.access;
|
|
99
|
+
if (a.length === 1 && a[0]?.kind === "field") return defaults.get(binding.name);
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Build the field-name -> rendered-default map for the struct root (else empty).
|
|
104
|
+
* Includes only non-optional defaulted fields (optional fields are
|
|
105
|
+
* presence-guarded; their default comes from the factory's kwarg signature). */
|
|
106
|
+
function collectDefaults(ctx: CodegenContext): Map<string, string> {
|
|
107
|
+
const out = new Map<string, string>();
|
|
108
|
+
const rootType = ctx.resolve(ctx.expr)?.type;
|
|
109
|
+
if (rootType?.kind !== "struct") return out;
|
|
110
|
+
for (const [name, fi] of collectFieldInfo(ctx, rootType)) {
|
|
111
|
+
if (fi.defaultValue === undefined) continue;
|
|
112
|
+
if (rootType.fields[name]?.kind === "optional") continue;
|
|
113
|
+
out.set(name, renderPyLiteral(fi.defaultValue));
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface WrapperRender {
|
|
119
|
+
open: string;
|
|
120
|
+
loopVar?: string;
|
|
121
|
+
/** A `local = params.get(...)` line emitted before `open` (optional gates). */
|
|
122
|
+
bindLine?: string;
|
|
123
|
+
/** `[accessPrefix, local]` to add to the child scope's `subst` map. */
|
|
124
|
+
subst?: [string, string];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let loopCounter = 0;
|
|
128
|
+
|
|
129
|
+
function renderWrapperOpen(atom: GateAtom, ec: OutputEmitCtx): WrapperRender {
|
|
130
|
+
if (atom.kind === "iter") {
|
|
131
|
+
const access = bindingAccess(atom.binding, ec);
|
|
132
|
+
const v = `__o${loopCounter++}`;
|
|
133
|
+
return { open: `for ${v} in ${access}:`, loopVar: v };
|
|
134
|
+
}
|
|
135
|
+
if (atom.kind === "variant") {
|
|
136
|
+
const access = bindingAccess(atom.binding, ec);
|
|
137
|
+
return { open: `if ${access}["@type"] == ${pyStr(atom.variant)}:` };
|
|
138
|
+
}
|
|
139
|
+
// present
|
|
140
|
+
const binding = ec.ctx.bindings.get(atom.binding);
|
|
141
|
+
if (binding?.type.kind === "optional") {
|
|
142
|
+
// Optional fields are NotRequired - the factory omits absent ones. Bind the
|
|
143
|
+
// value to a narrowed local read via `.get()` (a bare subscript would
|
|
144
|
+
// KeyError) and redirect inner reads to it via `subst`. Mirrors walkOptional.
|
|
145
|
+
const subscriptAccess = bindingAccess(atom.binding, ec);
|
|
146
|
+
const getAccess = bindingAccess(atom.binding, ec, true);
|
|
147
|
+
const local = `__v${loopCounter++}`;
|
|
148
|
+
return {
|
|
149
|
+
open: `if ${local} is not None:`,
|
|
150
|
+
bindLine: `${local} = ${getAccess}`,
|
|
151
|
+
subst: [subscriptAccess, local],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
// A bool flag / count gating an output is NotRequired (a hand-authored config
|
|
155
|
+
// may omit it), so read absence-safe via `.get()` - a bare subscript would
|
|
156
|
+
// KeyError. `presentCondition` coerces the possibly-None `.get()` result.
|
|
157
|
+
const t = binding?.type.kind;
|
|
158
|
+
const absentSafe = t === "bool" || t === "count";
|
|
159
|
+
const access = bindingAccess(atom.binding, ec, absentSafe);
|
|
160
|
+
const cond = presentCondition(binding?.type, access);
|
|
161
|
+
return { open: `if ${cond}:` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function presentCondition(type: BoundType | undefined, access: string): string {
|
|
165
|
+
if (!type) return access;
|
|
166
|
+
switch (type.kind) {
|
|
167
|
+
case "optional":
|
|
168
|
+
return `${access} is not None`;
|
|
169
|
+
case "bool":
|
|
170
|
+
// `access` is a `.get()` read: None (absent) is falsy -> flag off.
|
|
171
|
+
return access;
|
|
172
|
+
case "count":
|
|
173
|
+
// `access` is a `.get()` read: coerce None (absent) to 0 before comparing.
|
|
174
|
+
return `(${access} or 0) > 0`;
|
|
175
|
+
default:
|
|
176
|
+
return access;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function bindingAccess(
|
|
181
|
+
id: BindingId,
|
|
182
|
+
ec: OutputEmitCtx,
|
|
183
|
+
finalGet = false,
|
|
184
|
+
finalDefault?: string,
|
|
185
|
+
): string {
|
|
186
|
+
// The binding is itself the currently-iterated element (a ref to the list
|
|
187
|
+
// being looped, or a scalar list element): use its loop variable directly.
|
|
188
|
+
const iterVar = ec.iter.get(id);
|
|
189
|
+
if (iterVar) return iterVar;
|
|
190
|
+
const binding = ec.ctx.bindings.get(id);
|
|
191
|
+
if (binding) {
|
|
192
|
+
// Solver-assigned path; `iter` segments resolve to the loop variable bound
|
|
193
|
+
// by the surrounding `iter` gate atom. `finalGet` renders the last field
|
|
194
|
+
// segment as `.get()` when binding an optional's value; `finalDefault`
|
|
195
|
+
// renders it as `.get(key, default)` for a defaulted field; `subst` redirects
|
|
196
|
+
// an optional prefix to the narrowed local bound by its presence gate.
|
|
197
|
+
return renderAccess(
|
|
198
|
+
binding.access,
|
|
199
|
+
(b) => ec.iter.get(b) ?? `None # unresolved loop var ${b}`,
|
|
200
|
+
{
|
|
201
|
+
finalFieldGet: finalGet,
|
|
202
|
+
finalFieldDefault: finalDefault,
|
|
203
|
+
subst: ec.subst,
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return `None # unresolved binding ${id}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Render the path expression for an output's tokens. */
|
|
211
|
+
function renderPathExpr(tokens: ResolvedToken[], ec: OutputEmitCtx): string {
|
|
212
|
+
if (tokens.length === 0) return `""`;
|
|
213
|
+
if (tokens.length === 1) return renderToken(tokens[0]!, ec);
|
|
214
|
+
// f-string interpolation. Use a single-quoted outer so embedded subscript
|
|
215
|
+
// expressions like `params["key"]` (with double quotes) don't collide with
|
|
216
|
+
// the outer quote - PEP 701 (Python 3.12+) lifts this restriction, but we
|
|
217
|
+
// target 3.10+.
|
|
218
|
+
let result = "f'";
|
|
219
|
+
for (const tok of tokens) {
|
|
220
|
+
if (tok.kind === "literal") {
|
|
221
|
+
// Escape backslashes, single quotes, and braces (the latter are f-string
|
|
222
|
+
// metacharacters).
|
|
223
|
+
result += tok.value
|
|
224
|
+
.replace(/\\/g, "\\\\")
|
|
225
|
+
.replace(/'/g, "\\'")
|
|
226
|
+
.replace(/\{/g, "{{")
|
|
227
|
+
.replace(/\}/g, "}}");
|
|
228
|
+
} else {
|
|
229
|
+
result += "{";
|
|
230
|
+
result += renderRefValue(tok, ec);
|
|
231
|
+
result += "}";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
result += "'";
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderToken(tok: ResolvedToken, ec: OutputEmitCtx): string {
|
|
239
|
+
if (tok.kind === "literal") return pyStr(tok.value);
|
|
240
|
+
return renderRefValue(tok, ec);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function renderRefValue(tok: Extract<ResolvedToken, { kind: "ref" }>, ec: OutputEmitCtx): string {
|
|
244
|
+
// A defaulted root field interpolated into an output path is read absent-safe
|
|
245
|
+
// via `.get(key, default)` (it is `NotRequired`); other refs render normally.
|
|
246
|
+
const def = rootFieldDefault(ec.ctx.bindings.get(tok.binding), ec.defaults);
|
|
247
|
+
let expr =
|
|
248
|
+
def !== undefined && !ec.iter.has(tok.binding)
|
|
249
|
+
? bindingAccess(tok.binding, ec, false, def)
|
|
250
|
+
: bindingAccess(tok.binding, ec);
|
|
251
|
+
if (tok.fallback !== undefined) {
|
|
252
|
+
expr = `(${expr} if ${expr} is not None else ${pyStr(tok.fallback)})`;
|
|
253
|
+
}
|
|
254
|
+
if (tok.stripExtensions && tok.stripExtensions.length > 0) {
|
|
255
|
+
const sorted = [...tok.stripExtensions].sort((a, b) => b.length - a.length);
|
|
256
|
+
const lits = sorted.map((s) => pyStr(s)).join(", ");
|
|
257
|
+
expr = `_strip_extensions(${expr}, [${lits}])`;
|
|
258
|
+
}
|
|
259
|
+
return expr;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Emit one contributor's assignment into the field's shared local var,
|
|
264
|
+
* wrapped in its gate. The local var's init is emitted upfront by the caller
|
|
265
|
+
* (except for required-single fields, which declare at their first ungated
|
|
266
|
+
* assignment; `reassign` marks a later same-named contributor that must assign
|
|
267
|
+
* into the already-declared local rather than re-annotate it).
|
|
268
|
+
*/
|
|
269
|
+
function emitOneOutput(
|
|
270
|
+
output: EmittedOutput,
|
|
271
|
+
gate: GateAtom[],
|
|
272
|
+
fieldShape: OutputShape,
|
|
273
|
+
localVar: string,
|
|
274
|
+
reassign: boolean,
|
|
275
|
+
ec: OutputEmitCtx,
|
|
276
|
+
cb: CodeBuilder,
|
|
277
|
+
): void {
|
|
278
|
+
const typeAnnot = outputTypeExpr(fieldShape);
|
|
279
|
+
|
|
280
|
+
function nest(remaining: GateAtom[], child: OutputEmitCtx): void {
|
|
281
|
+
if (remaining.length === 0) {
|
|
282
|
+
const pathExpr = renderPathExpr(output.tokens, child);
|
|
283
|
+
// A mutable input's writable copy is surfaced via mutable_copy (its host
|
|
284
|
+
// path); a regular output resolves a local path via output_file.
|
|
285
|
+
const call = output.mutable
|
|
286
|
+
? `execution.mutable_copy(${pathExpr})`
|
|
287
|
+
: `execution.output_file(${pathExpr})`;
|
|
288
|
+
if (fieldShape.kind === "list") {
|
|
289
|
+
cb.line(`${localVar}.append(${call})`);
|
|
290
|
+
} else if (fieldShape.optional || reassign) {
|
|
291
|
+
// Optional fields init upfront; a required-single's second-or-later
|
|
292
|
+
// ungated contributor reassigns the already-declared local (a second
|
|
293
|
+
// annotated declaration would be a mypy `no-redef`).
|
|
294
|
+
cb.line(`${localVar} = ${call}`);
|
|
295
|
+
} else {
|
|
296
|
+
// Required single: the first ungated contributor declares the var here.
|
|
297
|
+
cb.line(`${localVar}: ${typeAnnot} = ${call}`);
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const [head, ...rest] = remaining;
|
|
302
|
+
if (!head) return;
|
|
303
|
+
const wrapper = renderWrapperOpen(head, child);
|
|
304
|
+
if (wrapper.bindLine) cb.line(wrapper.bindLine);
|
|
305
|
+
cb.line(wrapper.open);
|
|
306
|
+
cb.indent(() => {
|
|
307
|
+
let inner = child;
|
|
308
|
+
if (head.kind === "iter") {
|
|
309
|
+
inner = { ...child, iter: new Map(child.iter).set(head.binding, wrapper.loopVar!) };
|
|
310
|
+
} else if (wrapper.subst) {
|
|
311
|
+
inner = { ...child, subst: new Map(child.subst).set(wrapper.subst[0], wrapper.subst[1]) };
|
|
312
|
+
}
|
|
313
|
+
nest(rest, inner);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
nest(gate, ec);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Emit a standalone `_outputs(params, execution)` function that builds and
|
|
322
|
+
* returns the `Outputs` dataclass. Mirrors the `_cargs` function so the
|
|
323
|
+
* wrapper can just call both. Same-named outputs share one local var (init
|
|
324
|
+
* once, then every contributor assigns into it under its own gate), and the
|
|
325
|
+
* constructor receives one keyword argument per unique field.
|
|
326
|
+
*/
|
|
327
|
+
export function emitBuildOutputs(
|
|
328
|
+
ctx: CodegenContext,
|
|
329
|
+
paramsType: string,
|
|
330
|
+
outputsType: string,
|
|
331
|
+
funcName: string,
|
|
332
|
+
cb: CodeBuilder,
|
|
333
|
+
): void {
|
|
334
|
+
cb.line(`def ${funcName}(params: ${paramsType}, execution: Execution) -> ${outputsType}:`);
|
|
335
|
+
cb.indent(() => {
|
|
336
|
+
cb.line(`"""Build the ${outputsType} object for this tool."""`);
|
|
337
|
+
loopCounter = 0;
|
|
338
|
+
const ec: OutputEmitCtx = {
|
|
339
|
+
ctx,
|
|
340
|
+
iter: new Map(),
|
|
341
|
+
subst: new Map(),
|
|
342
|
+
defaults: collectDefaults(ctx),
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const fields = collectOutputFields(ctx, pyId);
|
|
346
|
+
const localVarOf = new Map<string, string>();
|
|
347
|
+
for (const f of fields) {
|
|
348
|
+
const localVar = `${f.id}_v`;
|
|
349
|
+
localVarOf.set(f.id, localVar);
|
|
350
|
+
// Initialize lists and gated singles upfront so each contributor only
|
|
351
|
+
// assigns or appends. Required-singles are declared at their (sole)
|
|
352
|
+
// ungated assignment - no init line here would leave the name unbound.
|
|
353
|
+
if (f.shape.kind === "list") cb.line(`${localVar}: list[OutputPathType] = []`);
|
|
354
|
+
else if (f.shape.optional) cb.line(`${localVar}: OutputPathType | None = None`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Required-single fields declare their local at the first ungated
|
|
358
|
+
// contributor; a same-named ungated contributor seen later must reassign
|
|
359
|
+
// (a second annotated declaration is a mypy `no-redef`). Some afni
|
|
360
|
+
// descriptors give two output-files the same id with no gate.
|
|
361
|
+
const declared = new Set<string>();
|
|
362
|
+
const emitContributor = (output: EmittedOutput, scopeGate: GateAtom[]): void => {
|
|
363
|
+
const gate = outputGate(scopeGate, output, ctx.bindings);
|
|
364
|
+
const id = pyId(output.name);
|
|
365
|
+
const field = fields.find((f) => f.id === id)!;
|
|
366
|
+
const reassign = declared.has(id);
|
|
367
|
+
declared.add(id);
|
|
368
|
+
emitOneOutput(output, gate, field.shape, localVarOf.get(id)!, reassign, ec, cb);
|
|
369
|
+
};
|
|
370
|
+
// The always-present root output directory, declared before any declared
|
|
371
|
+
// output (matches its first position in collectOutputFields).
|
|
372
|
+
emitContributor(rootOutput(ctx, pyId), []);
|
|
373
|
+
for (const scope of ctx.outputScopes) {
|
|
374
|
+
const scopeBinding = ctx.bindings.get(scope.scope);
|
|
375
|
+
const scopeGate = scopeBinding?.gate ?? [];
|
|
376
|
+
for (const output of scope.outputs) emitContributor(output, scopeGate);
|
|
377
|
+
}
|
|
378
|
+
for (const output of collectMutableOutputs(ctx)) emitContributor(output, []);
|
|
379
|
+
|
|
380
|
+
const streams = streamFields(ctx, pyId);
|
|
381
|
+
if (fields.length === 0 && streams.length === 0) {
|
|
382
|
+
cb.line(`return ${outputsType}()`);
|
|
383
|
+
} else {
|
|
384
|
+
cb.line(`return ${outputsType}(`);
|
|
385
|
+
cb.indent(() => {
|
|
386
|
+
for (const f of fields) cb.line(`${f.id}=${localVarOf.get(f.id)},`);
|
|
387
|
+
// Stream fields start empty; the wrapper appends to them via the
|
|
388
|
+
// handle_stdout / handle_stderr callbacks passed to execution.run.
|
|
389
|
+
for (const s of streams) cb.line(`${s.id}=[],`);
|
|
390
|
+
});
|
|
391
|
+
cb.line(")");
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Sanitize an output name to a valid Python identifier. */
|
|
397
|
+
function pyId(name: string): string {
|
|
398
|
+
let s = name.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
399
|
+
if (/^\d/.test(s)) s = "_" + s;
|
|
400
|
+
if (s === "") s = "_";
|
|
401
|
+
if (PY_KEYWORDS.has(s)) s = s + "_";
|
|
402
|
+
return s;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Whether any output reference has stripExtensions set. */
|
|
406
|
+
export function needsStripExtensionsHelper(ctx: CodegenContext): boolean {
|
|
407
|
+
for (const scope of ctx.outputScopes) {
|
|
408
|
+
for (const output of scope.outputs) {
|
|
409
|
+
for (const tok of output.tokens) {
|
|
410
|
+
if (tok.kind === "ref" && tok.stripExtensions?.length) return true;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Emit a small `_strip_extensions` helper used by ref tokens that strip suffixes. */
|
|
418
|
+
export function emitStripExtensionsHelper(cb: CodeBuilder): void {
|
|
419
|
+
cb.line("def _strip_extensions(value: str, exts: list[str]) -> str:");
|
|
420
|
+
cb.indent(() => {
|
|
421
|
+
cb.line("for ext in exts:");
|
|
422
|
+
cb.indent(() => {
|
|
423
|
+
cb.line("if value.endswith(ext):");
|
|
424
|
+
cb.indent(() => {
|
|
425
|
+
cb.line("return value[: -len(ext)]");
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
cb.line("return value");
|
|
429
|
+
});
|
|
430
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Documentation } from "../../ir/index.js";
|
|
2
|
+
import type { PackageMeta, ProjectMeta } from "../../manifest/index.js";
|
|
3
|
+
import { CodeBuilder } from "../code-builder.js";
|
|
4
|
+
import { PYTHON_RUNNER_DEPS, STYXDEFS_COMPAT } from "../styxdefs-compat.js";
|
|
5
|
+
|
|
6
|
+
const REQUIRES_PYTHON = ">=3.10";
|
|
7
|
+
const BUILD_SYSTEM = `[build-system]
|
|
8
|
+
requires = ["setuptools>=61"]
|
|
9
|
+
build-backend = "setuptools.build_meta"`;
|
|
10
|
+
|
|
11
|
+
/** Escape a value for embedding in a TOML basic string. */
|
|
12
|
+
function tomlStr(s: string): string {
|
|
13
|
+
return (
|
|
14
|
+
s
|
|
15
|
+
.replace(/\\/g, "\\\\")
|
|
16
|
+
.replace(/"/g, '\\"')
|
|
17
|
+
.replace(/[\r\n]+/g, " ")
|
|
18
|
+
// TOML basic strings forbid literal control chars (tab U+0009 is allowed);
|
|
19
|
+
// strip the rest so scraped docs can't produce invalid TOML.
|
|
20
|
+
// eslint-disable-next-line no-control-regex
|
|
21
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
|
|
22
|
+
.trim()
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Suite directory / importable package name; matches the CLI's `pkgDir` fallback. */
|
|
27
|
+
function pkgDir(pkg: PackageMeta): string {
|
|
28
|
+
return pkg.name ?? "package";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Distribution (PyPI) name for a package: `<project>_<package>`, or just `<package>`. */
|
|
32
|
+
export function pyDistName(proj: ProjectMeta, pkg: PackageMeta): string {
|
|
33
|
+
const name = pkgDir(pkg);
|
|
34
|
+
return proj.name ? `${proj.name}_${name}` : name;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// pyproject's `[project] description` becomes the core-metadata `Summary` field,
|
|
38
|
+
// which PyPI caps at 512 chars. The catalog's `doc.description` is often a full
|
|
39
|
+
// paragraph (and is emitted in full into the README / long description anyway),
|
|
40
|
+
// so the summary is clamped to fit.
|
|
41
|
+
const SUMMARY_MAX_LEN = 512;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Clamp a description to a <=512-char one-line summary. Prefer cutting at the
|
|
45
|
+
* last complete sentence that fits (clean, no dangling fragment); if there is no
|
|
46
|
+
* sentence boundary early enough, cut at a word boundary and mark the elision.
|
|
47
|
+
*/
|
|
48
|
+
function clampSummary(s: string): string {
|
|
49
|
+
if (s.length <= SUMMARY_MAX_LEN) return s;
|
|
50
|
+
const window = s.slice(0, SUMMARY_MAX_LEN);
|
|
51
|
+
const lastSentence = window.lastIndexOf(". ");
|
|
52
|
+
if (lastSentence >= 0) return s.slice(0, lastSentence + 1); // keep the period
|
|
53
|
+
const ellipsis = "...";
|
|
54
|
+
const body = window.slice(0, SUMMARY_MAX_LEN - ellipsis.length);
|
|
55
|
+
const lastSpace = body.lastIndexOf(" ");
|
|
56
|
+
return (lastSpace > 0 ? body.slice(0, lastSpace) : body).replace(/[.,;:\s]+$/, "") + ellipsis;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function description(doc: Documentation | undefined, fallbackName: string | undefined): string {
|
|
60
|
+
const summary =
|
|
61
|
+
doc?.description ?? `Styx generated wrappers for ${doc?.title ?? fallbackName ?? "tools"}.`;
|
|
62
|
+
return clampSummary(summary);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function authorsField(doc: Documentation | undefined): string {
|
|
66
|
+
const authors = doc?.authors?.length ? doc.authors : ["unknown"];
|
|
67
|
+
return `[${authors.map((a) => `{ name = "${tomlStr(a)}" }`).join(", ")}]`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function licenseField(proj: ProjectMeta): string {
|
|
71
|
+
return `{ text = "${tomlStr(proj.license?.description ?? "unknown")}" }`;
|
|
72
|
+
}
|
|
73
|
+
|
|
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
|
|
78
|
+
* floor is the only runtime dependency.
|
|
79
|
+
*
|
|
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.
|
|
83
|
+
*/
|
|
84
|
+
export function generateSubPyproject(proj: ProjectMeta, pkg: PackageMeta): string {
|
|
85
|
+
const importName = pkgDir(pkg);
|
|
86
|
+
const cb = new CodeBuilder(" ");
|
|
87
|
+
cb.line("[project]");
|
|
88
|
+
cb.line(`name = "${tomlStr(pyDistName(proj, pkg))}"`);
|
|
89
|
+
// The wrapper distribution is released as part of the project, so it carries
|
|
90
|
+
// the project (catalog) version - NOT the wrapped tool's version. This keeps
|
|
91
|
+
// every niwrap_<pkg> in lockstep with the niwrap meta package, matching the
|
|
92
|
+
// single-package TypeScript distribution and the v1 release scheme.
|
|
93
|
+
cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
|
|
94
|
+
cb.line(`description = "${tomlStr(description(pkg.doc, pkg.name))}"`);
|
|
95
|
+
cb.line(`readme = "README.md"`);
|
|
96
|
+
cb.line(`license = ${licenseField(proj)}`);
|
|
97
|
+
cb.line(`authors = ${authorsField(pkg.doc ?? proj.doc)}`);
|
|
98
|
+
cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
|
|
99
|
+
cb.line("dependencies = [");
|
|
100
|
+
cb.line(` "styxdefs${STYXDEFS_COMPAT.python}",`);
|
|
101
|
+
cb.line("]");
|
|
102
|
+
cb.blank();
|
|
103
|
+
cb.line("[tool.setuptools]");
|
|
104
|
+
cb.line(`packages = ["${importName}"]`);
|
|
105
|
+
cb.line(`package-dir = { "${importName}" = "." }`);
|
|
106
|
+
cb.blank();
|
|
107
|
+
cb.line("[tool.setuptools.package-data]");
|
|
108
|
+
cb.line(`"${importName}" = ["py.typed"]`);
|
|
109
|
+
cb.blank();
|
|
110
|
+
cb.line(BUILD_SYSTEM);
|
|
111
|
+
return cb.toString() + "\n";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Root `pyproject.toml`: a metapackage depending on each per-suite distribution
|
|
116
|
+
* plus the container/graph runner packages. `packages = []` keeps setuptools
|
|
117
|
+
* from sweeping the sibling suite directories into this distribution.
|
|
118
|
+
*/
|
|
119
|
+
export function generateRootPyproject(proj: ProjectMeta, distNames: string[]): string {
|
|
120
|
+
const cb = new CodeBuilder(" ");
|
|
121
|
+
cb.line("[project]");
|
|
122
|
+
cb.line(`name = "${tomlStr(proj.name ?? "project")}"`);
|
|
123
|
+
cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
|
|
124
|
+
cb.line(`description = "${tomlStr(description(proj.doc, proj.name))}"`);
|
|
125
|
+
cb.line(`readme = "README.md"`);
|
|
126
|
+
cb.line(`license = ${licenseField(proj)}`);
|
|
127
|
+
cb.line(`authors = ${authorsField(proj.doc)}`);
|
|
128
|
+
cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
|
|
129
|
+
cb.line("dependencies = [");
|
|
130
|
+
for (const dep of PYTHON_RUNNER_DEPS) cb.line(` "${dep}",`);
|
|
131
|
+
for (const dist of distNames) cb.line(` "${tomlStr(dist)}",`);
|
|
132
|
+
cb.line("]");
|
|
133
|
+
cb.blank();
|
|
134
|
+
cb.line("[tool.setuptools]");
|
|
135
|
+
cb.line("packages = []");
|
|
136
|
+
cb.blank();
|
|
137
|
+
cb.line(BUILD_SYSTEM);
|
|
138
|
+
return cb.toString() + "\n";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Per-suite README crediting the upstream tool authors. */
|
|
142
|
+
export function generateSubReadme(proj: ProjectMeta, pkg: PackageMeta): string {
|
|
143
|
+
const projectTitle = proj.doc?.title ?? proj.name ?? "Styx";
|
|
144
|
+
const packageTitle = pkg.doc?.title ?? pkg.name ?? "package";
|
|
145
|
+
const url = pkg.doc?.urls?.[0];
|
|
146
|
+
const titleMd = url ? `[${packageTitle}](${url})` : packageTitle;
|
|
147
|
+
const credits = pkg.doc?.authors?.length
|
|
148
|
+
? pkg.doc.authors.join(", ")
|
|
149
|
+
: (pkg.doc?.urls?.join(", ") ?? "unknown");
|
|
150
|
+
const desc = pkg.doc?.description ? `\n\n${pkg.doc.description}` : "";
|
|
151
|
+
return (
|
|
152
|
+
`# ${projectTitle} wrappers for ${titleMd}${desc}\n\n` +
|
|
153
|
+
`${packageTitle} is made by ${credits}.\n\n` +
|
|
154
|
+
`This package contains wrappers only and has no affiliation with the original authors.\n`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Root README listing the bundled per-suite distributions. */
|
|
159
|
+
export function generateRootReadme(proj: ProjectMeta, distNames: string[]): string {
|
|
160
|
+
const title = proj.doc?.title ?? proj.name ?? "Styx";
|
|
161
|
+
const desc = proj.doc?.description ? `\n${proj.doc.description}\n` : "";
|
|
162
|
+
const list = distNames.map((d) => `- ${d}`).join("\n");
|
|
163
|
+
return (
|
|
164
|
+
`# ${title}\n${desc}\n` +
|
|
165
|
+
`Auto-generated Styx wrappers. This project bundles the following packages:\n\n` +
|
|
166
|
+
`${list}\n`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Local-install manifest: each suite directory first, then the root metapackage. */
|
|
171
|
+
export function generateRequirementsTxt(pkgDirs: string[]): string {
|
|
172
|
+
return [...pkgDirs.map((d) => `./${d}`), "./"].join("\n") + "\n";
|
|
173
|
+
}
|