@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,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
+ }