@styx-api/core 0.4.0 → 0.5.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 CHANGED
@@ -3388,196 +3388,6 @@ function resolveTypeName(namedTypes) {
3388
3388
  };
3389
3389
  }
3390
3390
 
3391
- //#endregion
3392
- //#region src/backend/styxdefs-compat.ts
3393
- /**
3394
- * Runtime version floors baked into generated dependency metadata.
3395
- *
3396
- * styx2-generated code calls `mutable_copy` / `mutableCopy` (introduced in the
3397
- * styxdefs 0.7.0 / styxdefs-js 0.2.0 release), so emitted packages genuinely
3398
- * require that runtime floor. This is the single source of truth: bump here and
3399
- * both the Python and TypeScript backends pick it up.
3400
- */
3401
- const STYXDEFS_COMPAT = {
3402
- python: ">=0.7.0,<0.8.0",
3403
- npm: "^0.2.0"
3404
- };
3405
- /**
3406
- * Extra Python runtime packages the root distribution pulls in (container +
3407
- * graph runners). Left unpinned - styxdefs's floor constrains them transitively
3408
- * via their own inter-package pins.
3409
- */
3410
- const PYTHON_RUNNER_DEPS = [
3411
- "styxdocker",
3412
- "styxsingularity",
3413
- "styxgraph"
3414
- ];
3415
-
3416
- //#endregion
3417
- //#region src/backend/python/packaging.ts
3418
- const REQUIRES_PYTHON = ">=3.10";
3419
- const BUILD_SYSTEM = `[build-system]
3420
- requires = ["setuptools>=61"]
3421
- build-backend = "setuptools.build_meta"`;
3422
- /** Escape a value for embedding in a TOML basic string. */
3423
- function tomlStr(s) {
3424
- return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\r\n]+/g, " ").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
3425
- }
3426
- /** Suite directory / importable package name; matches the CLI's `pkgDir` fallback. */
3427
- function pkgDir(pkg) {
3428
- return pkg.name ?? "package";
3429
- }
3430
- /** Distribution (PyPI) name for a package: `<project>_<package>`, or just `<package>`. */
3431
- function pyDistName(proj, pkg) {
3432
- const name = pkgDir(pkg);
3433
- return proj.name ? `${proj.name}_${name}` : name;
3434
- }
3435
- const SUMMARY_MAX_LEN = 512;
3436
- /**
3437
- * Clamp a description to a <=512-char one-line summary. Prefer cutting at the
3438
- * last complete sentence that fits (clean, no dangling fragment); if there is no
3439
- * sentence boundary early enough, cut at a word boundary and mark the elision.
3440
- */
3441
- function clampSummary(s) {
3442
- if (s.length <= SUMMARY_MAX_LEN) return s;
3443
- const window = s.slice(0, SUMMARY_MAX_LEN);
3444
- const lastSentence = window.lastIndexOf(". ");
3445
- if (lastSentence >= 0) return s.slice(0, lastSentence + 1);
3446
- const ellipsis = "...";
3447
- const body = window.slice(0, SUMMARY_MAX_LEN - 3);
3448
- const lastSpace = body.lastIndexOf(" ");
3449
- return (lastSpace > 0 ? body.slice(0, lastSpace) : body).replace(/[.,;:\s]+$/, "") + ellipsis;
3450
- }
3451
- function description$1(doc, fallbackName) {
3452
- return clampSummary(doc?.description ?? `Styx generated wrappers for ${doc?.title ?? fallbackName ?? "tools"}.`);
3453
- }
3454
- function authorsField(doc) {
3455
- return `[${(doc?.authors?.length ? doc.authors : ["unknown"]).map((a) => `{ name = "${tomlStr(a)}" }`).join(", ")}]`;
3456
- }
3457
- function licenseField(proj) {
3458
- return `{ text = "${tomlStr(proj.license?.description ?? "unknown")}" }`;
3459
- }
3460
- /**
3461
- * Per-suite `pyproject.toml`. The flat layout (`python/<pkg>/bet.py`) makes the
3462
- * directory itself the importable package, so setuptools' `package-dir` maps the
3463
- * import name (`<pkg>`) onto the distribution's root directory. The styxdefs
3464
- * floor is the only runtime dependency.
3465
- *
3466
- * Precondition: `pkg.name` must be a valid Python identifier - the flat layout's
3467
- * relative imports (`from .bet import *`) already require this, and the CLI uses
3468
- * it verbatim as the directory name, so this stays consistent with that.
3469
- */
3470
- function generateSubPyproject(proj, pkg) {
3471
- const importName = pkgDir(pkg);
3472
- const cb = new CodeBuilder(" ");
3473
- cb.line("[project]");
3474
- cb.line(`name = "${tomlStr(pyDistName(proj, pkg))}"`);
3475
- cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
3476
- cb.line(`description = "${tomlStr(description$1(pkg.doc, pkg.name))}"`);
3477
- cb.line(`readme = "README.md"`);
3478
- cb.line(`license = ${licenseField(proj)}`);
3479
- cb.line(`authors = ${authorsField(pkg.doc ?? proj.doc)}`);
3480
- cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
3481
- cb.line("dependencies = [");
3482
- cb.line(` "styxdefs${STYXDEFS_COMPAT.python}",`);
3483
- cb.line("]");
3484
- cb.blank();
3485
- cb.line("[tool.setuptools]");
3486
- cb.line(`packages = ["${importName}"]`);
3487
- cb.line(`package-dir = { "${importName}" = "." }`);
3488
- cb.blank();
3489
- cb.line("[tool.setuptools.package-data]");
3490
- cb.line(`"${importName}" = ["py.typed"]`);
3491
- cb.blank();
3492
- cb.line(BUILD_SYSTEM);
3493
- return cb.toString() + "\n";
3494
- }
3495
- /**
3496
- * Root `pyproject.toml`: a metapackage depending on each per-suite distribution
3497
- * plus the container/graph runner packages. `packages = []` keeps setuptools
3498
- * from sweeping the sibling suite directories into this distribution.
3499
- */
3500
- function generateRootPyproject(proj, distNames) {
3501
- const cb = new CodeBuilder(" ");
3502
- cb.line("[project]");
3503
- cb.line(`name = "${tomlStr(proj.name ?? "project")}"`);
3504
- cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
3505
- cb.line(`description = "${tomlStr(description$1(proj.doc, proj.name))}"`);
3506
- cb.line(`readme = "README.md"`);
3507
- cb.line(`license = ${licenseField(proj)}`);
3508
- cb.line(`authors = ${authorsField(proj.doc)}`);
3509
- cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
3510
- cb.line("dependencies = [");
3511
- for (const dep of PYTHON_RUNNER_DEPS) cb.line(` "${dep}",`);
3512
- for (const dist of distNames) cb.line(` "${tomlStr(dist)}",`);
3513
- cb.line("]");
3514
- cb.blank();
3515
- cb.line("[tool.setuptools]");
3516
- cb.line("packages = []");
3517
- cb.blank();
3518
- cb.line(BUILD_SYSTEM);
3519
- return cb.toString() + "\n";
3520
- }
3521
- /** Per-suite README crediting the upstream tool authors. */
3522
- function generateSubReadme(proj, pkg) {
3523
- const projectTitle = proj.doc?.title ?? proj.name ?? "Styx";
3524
- const packageTitle = pkg.doc?.title ?? pkg.name ?? "package";
3525
- const url = pkg.doc?.urls?.[0];
3526
- const titleMd = url ? `[${packageTitle}](${url})` : packageTitle;
3527
- const credits = pkg.doc?.authors?.length ? pkg.doc.authors.join(", ") : pkg.doc?.urls?.join(", ") ?? "unknown";
3528
- return `# ${projectTitle} wrappers for ${titleMd}${pkg.doc?.description ? `\n\n${pkg.doc.description}` : ""}\n\n${packageTitle} is made by ${credits}.\n\nThis package contains wrappers only and has no affiliation with the original authors.\n`;
3529
- }
3530
- /** Root README listing the bundled per-suite distributions. */
3531
- function generateRootReadme(proj, distNames) {
3532
- return `# ${proj.doc?.title ?? proj.name ?? "Styx"}\n${proj.doc?.description ? `\n${proj.doc.description}\n` : ""}\nAuto-generated Styx wrappers. This project bundles the following packages:\n\n${distNames.map((d) => `- ${d}`).join("\n")}\n`;
3533
- }
3534
- /** Local-install manifest: each suite directory first, then the root metapackage. */
3535
- function generateRequirementsTxt(pkgDirs) {
3536
- return [...pkgDirs.map((d) => `./${d}`), "./"].join("\n") + "\n";
3537
- }
3538
-
3539
- //#endregion
3540
- //#region src/backend/sig-entries.ts
3541
- /**
3542
- * Build per-field signature entries for the kwarg wrapper and params factory.
3543
- * Skips `@type` (the factory injects it as a constant). Required-no-default
3544
- * entries are placed before defaulted ones so the resulting signature is
3545
- * syntactically valid in both Python and TS.
3546
- *
3547
- * `registerLocal` is called once per field with the wire key; it must return
3548
- * a scrubbed, unique host identifier (typically by combining a language-aware
3549
- * scrub function with `Scope.add()`). The caller's scope should already have
3550
- * the function's other locals (`params`, `runner`, ...) reserved so this
3551
- * registration cannot collide with them.
3552
- */
3553
- function buildSigEntries(rootType, fieldInfo, registerLocal, opts) {
3554
- const entries = [];
3555
- for (const [fieldName, fieldType] of Object.entries(rootType.fields)) {
3556
- if (fieldType.kind === "literal") continue;
3557
- const fi = fieldInfo.get(fieldName);
3558
- const isOptional = fieldType.kind === "optional";
3559
- const inner = isOptional ? fieldType.inner : fieldType;
3560
- let sigType = opts.renderType(inner);
3561
- if (isOptional) sigType += opts.nullableSuffix;
3562
- let sigDefault;
3563
- const hasExplicitDefault = fi?.defaultValue !== void 0;
3564
- if (hasExplicitDefault) sigDefault = opts.renderDefault(fi.defaultValue);
3565
- else if (isOptional) sigDefault = opts.nullableDefault;
3566
- entries.push({
3567
- name: registerLocal(fieldName),
3568
- wireKey: fieldName,
3569
- sigType,
3570
- sigDefault,
3571
- isOptional,
3572
- hasExplicitDefault,
3573
- doc: fi?.doc
3574
- });
3575
- }
3576
- const required = entries.filter((e) => e.sigDefault === void 0);
3577
- const defaulted = entries.filter((e) => e.sigDefault !== void 0);
3578
- return [...required, ...defaulted];
3579
- }
3580
-
3581
3391
  //#endregion
3582
3392
  //#region src/backend/union-variants.ts
3583
3393
  /**
@@ -4346,54 +4156,444 @@ function emitParamsFactory$1(entries, funcName, paramsType, typeTag, cb) {
4346
4156
  });
4347
4157
  }
4348
4158
  /**
4349
- * Emit the user-facing kwarg wrapper: takes the same kwargs as `_params()`
4350
- * plus `runner`, builds the params dict, and delegates to the dict-style
4351
- * execute function.
4159
+ * Emit the user-facing kwarg wrapper: takes the same kwargs as `_params()`
4160
+ * plus `runner`, builds the params dict, and delegates to the dict-style
4161
+ * execute function.
4162
+ */
4163
+ function emitKwargWrapper$1(ctx, entries, funcName, paramsFnName, executeFnName, outputsType, cb) {
4164
+ cb.line(`def ${funcName}(`);
4165
+ cb.indent(() => {
4166
+ emitSigParams$1(entries, cb);
4167
+ cb.line("runner: Runner | None = None,");
4168
+ });
4169
+ const returnType = outputsType ?? "None";
4170
+ cb.line(`) -> ${returnType}:`);
4171
+ cb.indent(() => {
4172
+ const appDoc = ctx.app?.doc;
4173
+ cb.line("\"\"\"");
4174
+ if (appDoc?.title) cb.line(appDoc.title);
4175
+ if (appDoc?.description) {
4176
+ if (appDoc?.title) cb.blank();
4177
+ cb.line(appDoc.description);
4178
+ }
4179
+ if (appDoc?.authors?.length) {
4180
+ cb.blank();
4181
+ cb.line(`Author: ${appDoc.authors.join(", ")}`);
4182
+ }
4183
+ if (appDoc?.urls?.length) {
4184
+ cb.blank();
4185
+ cb.line(`URL: ${appDoc.urls[0]}`);
4186
+ }
4187
+ cb.blank();
4188
+ emitArgsBlock([...entries, {
4189
+ name: "runner",
4190
+ doc: "Command runner (defaults to global runner)."
4191
+ }], cb);
4192
+ cb.blank();
4193
+ cb.line("Returns:");
4194
+ cb.line(outputsType ? " Tool outputs (paths to files produced by the tool)." : " None.");
4195
+ cb.line("\"\"\"");
4196
+ if (entries.length === 0) cb.line(`params = ${paramsFnName}()`);
4197
+ else {
4198
+ cb.line(`params = ${paramsFnName}(`);
4199
+ cb.indent(() => {
4200
+ for (const e of entries) cb.line(`${e.name}=${e.name},`);
4201
+ });
4202
+ cb.line(")");
4203
+ }
4204
+ if (outputsType) cb.line(`return ${executeFnName}(params, runner)`);
4205
+ else cb.line(`${executeFnName}(params, runner)`);
4206
+ });
4207
+ }
4208
+
4209
+ //#endregion
4210
+ //#region src/backend/nipype/emit.ts
4211
+ function call(ctor, args) {
4212
+ return `${ctor}(${args.join(", ")})`;
4213
+ }
4214
+ /**
4215
+ * Render a numeric literal, forcing a float form (e.g. `0.0`) for a float field.
4216
+ * `traits.Range` infers its numeric type from the bound literals, so integer
4217
+ * bounds on a float field (e.g. `low=0, high=1`) would build an *integer* range
4218
+ * that rejects `0.5`. Emitting `0.0`/`1.0` keeps it a float range.
4219
+ */
4220
+ function renderNum(value, asFloat) {
4221
+ if (asFloat && typeof value === "number" && Number.isInteger(value)) return `${value}.0`;
4222
+ return renderPyLiteral(value);
4223
+ }
4224
+ /** Human-readable `desc=` text: doc + degrade/media-type notes. */
4225
+ function descText(p) {
4226
+ const parts = [];
4227
+ if (p.doc) parts.push(p.doc);
4228
+ if (p.kind === "struct" || p.kind === "union") parts.push("(nested configuration; pass a dict)");
4229
+ if (p.mediaTypes && p.mediaTypes.length > 0) parts.push(`(media types: ${p.mediaTypes.join(", ")})`);
4230
+ return parts.length > 0 ? parts.join(" ") : void 0;
4231
+ }
4232
+ /** Map a list element descriptor to a (bare) nipype inner trait. */
4233
+ function renderItemTrait(item) {
4234
+ if (!item) return "traits.Any()";
4235
+ switch (item.kind) {
4236
+ case "path": return "File(exists=True)";
4237
+ case "int":
4238
+ case "count": return "traits.Int()";
4239
+ case "float": return "traits.Float()";
4240
+ case "str": return "traits.Str()";
4241
+ case "bool": return "traits.Bool()";
4242
+ case "enum": return call("traits.Enum", (item.choices ?? []).map((c) => renderPyLiteral(c)));
4243
+ default: return "traits.Any()";
4244
+ }
4245
+ }
4246
+ /** Map a parameter to its nipype input trait expression, carrying rich constraints. */
4247
+ function renderInputTrait(p) {
4248
+ const tail = [];
4249
+ if (p.mandatory) tail.push("mandatory=True");
4250
+ const desc = descText(p);
4251
+ if (desc) tail.push(`desc=${pyStr(desc)}`);
4252
+ const hasDef = p.hasDefault && p.default !== void 0;
4253
+ const def = p.default;
4254
+ switch (p.kind) {
4255
+ case "path": return call("File", ["exists=True", ...tail]);
4256
+ case "bool": return call("traits.Bool", [...hasDef ? [renderPyLiteral(def), "usedefault=True"] : [], ...tail]);
4257
+ case "count": return call("traits.Int", [...hasDef ? [renderPyLiteral(def), "usedefault=True"] : [], ...tail]);
4258
+ case "int":
4259
+ case "float": {
4260
+ const asFloat = p.kind === "float";
4261
+ if (p.range) {
4262
+ const a = [];
4263
+ if (hasDef) a.push(`value=${renderNum(def, asFloat)}`);
4264
+ if (p.range.min !== void 0) a.push(`low=${renderNum(p.range.min, asFloat)}`);
4265
+ if (p.range.max !== void 0) a.push(`high=${renderNum(p.range.max, asFloat)}`);
4266
+ if (hasDef) a.push("usedefault=True");
4267
+ return call("traits.Range", [...a, ...tail]);
4268
+ }
4269
+ return call(p.kind === "int" ? "traits.Int" : "traits.Float", [...hasDef ? [renderNum(def, asFloat), "usedefault=True"] : [], ...tail]);
4270
+ }
4271
+ case "str": return call("traits.Str", [...hasDef ? [renderPyLiteral(def), "usedefault=True"] : [], ...tail]);
4272
+ case "enum": {
4273
+ const choices = p.choices ?? [];
4274
+ return call("traits.Enum", [
4275
+ ...(hasDef ? [def, ...choices.filter((c) => c !== def)] : choices).map((c) => renderPyLiteral(c)),
4276
+ ...hasDef ? ["usedefault=True"] : [],
4277
+ ...tail
4278
+ ]);
4279
+ }
4280
+ case "list": {
4281
+ const inner = renderItemTrait(p.itemType);
4282
+ const bounds = [];
4283
+ if (p.listBounds?.min !== void 0) bounds.push(`minlen=${p.listBounds.min}`);
4284
+ if (p.listBounds?.max !== void 0) bounds.push(`maxlen=${p.listBounds.max}`);
4285
+ return call("traits.List", [
4286
+ inner,
4287
+ ...bounds,
4288
+ ...tail
4289
+ ]);
4290
+ }
4291
+ case "struct":
4292
+ case "union": return call("traits.Any", tail);
4293
+ }
4294
+ }
4295
+ /**
4296
+ * Map an output field to its nipype output trait expression. `isRoot` is the
4297
+ * synthetic output directory, typed as a `Directory` rather than a `File`.
4298
+ */
4299
+ function renderOutputTrait(f, isRoot) {
4300
+ const tail = f.doc ? [`desc=${pyStr(f.doc)}`] : [];
4301
+ if (f.shape.kind === "list") return call("traits.List", ["File()", ...tail]);
4302
+ if (isRoot) return call("Directory", tail);
4303
+ return call("File", tail);
4304
+ }
4305
+ /**
4306
+ * Emit the nipype interface module for one tool: a typed InputSpec/OutputSpec and
4307
+ * a BaseInterface that delegates execution to the co-emitted styx Python wrapper.
4308
+ */
4309
+ function emitNipypeInterface(ctx, spec, names) {
4310
+ const cb = new CodeBuilder(" ");
4311
+ cb.comment("This file was auto generated by Styx.", "# ");
4312
+ cb.comment("Do not edit this file directly.", "# ");
4313
+ cb.blank();
4314
+ cb.line("from nipype.interfaces.base import (");
4315
+ cb.indent(() => {
4316
+ cb.line("BaseInterface,");
4317
+ cb.line("BaseInterfaceInputSpec,");
4318
+ cb.line("Directory,");
4319
+ cb.line("File,");
4320
+ cb.line("TraitedSpec,");
4321
+ cb.line("isdefined,");
4322
+ cb.line("traits,");
4323
+ });
4324
+ cb.line(")");
4325
+ cb.blank();
4326
+ cb.line(`from .${names.styxStem} import ${spec.delegation.wrapperFn}, ${spec.delegation.outputsClass}`);
4327
+ cb.blank();
4328
+ cb.blank();
4329
+ cb.line(`class ${names.inputSpec}(BaseInterfaceInputSpec):`);
4330
+ cb.indent(() => {
4331
+ if (!spec.rootIsStruct || spec.params.length === 0) {
4332
+ cb.line("pass");
4333
+ return;
4334
+ }
4335
+ for (const p of spec.params) cb.line(`${p.hostName} = ${renderInputTrait(p)}`);
4336
+ });
4337
+ cb.blank();
4338
+ cb.blank();
4339
+ cb.line(`class ${names.outputSpec}(TraitedSpec):`);
4340
+ cb.indent(() => {
4341
+ if (spec.outputs.length === 0 && spec.streams.length === 0) {
4342
+ cb.line("pass");
4343
+ return;
4344
+ }
4345
+ spec.outputs.forEach((f, i) => cb.line(`${f.id} = ${renderOutputTrait(f, i === 0)}`));
4346
+ for (const s of spec.streams) {
4347
+ const tail = s.doc ? `, desc=${pyStr(s.doc)}` : "";
4348
+ cb.line(`${s.id} = traits.List(traits.Str()${tail})`);
4349
+ }
4350
+ });
4351
+ cb.blank();
4352
+ cb.blank();
4353
+ cb.line(`class ${names.cls}(BaseInterface):`);
4354
+ cb.indent(() => {
4355
+ emitDocstring(cb, [ctx.app?.doc?.title, ctx.app?.doc?.description].filter(Boolean).join("\n\n") || void 0);
4356
+ cb.line(`input_spec = ${names.inputSpec}`);
4357
+ cb.line(`output_spec = ${names.outputSpec}`);
4358
+ cb.blank();
4359
+ cb.line("def _run_interface(self, runtime):");
4360
+ cb.indent(() => {
4361
+ if (!spec.rootIsStruct) {
4362
+ cb.line("raise NotImplementedError(");
4363
+ cb.indent(() => cb.line(pyStr("styx nipype backend: tools with a non-struct root are not supported.")));
4364
+ cb.line(")");
4365
+ return;
4366
+ }
4367
+ cb.line("kwargs = {}");
4368
+ for (const p of spec.params) if (p.mandatory) cb.line(`kwargs[${pyStr(p.hostName)}] = self.inputs.${p.hostName}`);
4369
+ else {
4370
+ cb.line(`if isdefined(self.inputs.${p.hostName}):`);
4371
+ cb.indent(() => cb.line(`kwargs[${pyStr(p.hostName)}] = self.inputs.${p.hostName}`));
4372
+ }
4373
+ cb.line(`self._result: ${spec.delegation.outputsClass} = ${spec.delegation.wrapperFn}(**kwargs)`);
4374
+ cb.line("return runtime");
4375
+ });
4376
+ cb.blank();
4377
+ cb.line("def _list_outputs(self):");
4378
+ cb.indent(() => {
4379
+ if (!spec.rootIsStruct) {
4380
+ cb.line("return self._outputs().get()");
4381
+ return;
4382
+ }
4383
+ cb.line("result = self._result");
4384
+ cb.line("outputs = self._outputs().get()");
4385
+ for (const f of spec.outputs) if (f.shape.kind === "single" && f.shape.optional) {
4386
+ cb.line(`if result.${f.id} is not None:`);
4387
+ cb.indent(() => cb.line(`outputs[${pyStr(f.id)}] = result.${f.id}`));
4388
+ } else cb.line(`outputs[${pyStr(f.id)}] = result.${f.id}`);
4389
+ for (const s of spec.streams) cb.line(`outputs[${pyStr(s.id)}] = result.${s.id}`);
4390
+ cb.line("return outputs");
4391
+ });
4392
+ });
4393
+ return cb.toString();
4394
+ }
4395
+
4396
+ //#endregion
4397
+ //#region src/backend/styxdefs-compat.ts
4398
+ /**
4399
+ * Runtime version floors baked into generated dependency metadata.
4400
+ *
4401
+ * styx2-generated code calls `mutable_copy` / `mutableCopy` (introduced in the
4402
+ * styxdefs 0.7.0 / styxdefs-js 0.2.0 release), so emitted packages genuinely
4403
+ * require that runtime floor. This is the single source of truth: bump here and
4404
+ * both the Python and TypeScript backends pick it up.
4405
+ */
4406
+ const STYXDEFS_COMPAT = {
4407
+ python: ">=0.7.0,<0.8.0",
4408
+ npm: "^0.2.0"
4409
+ };
4410
+ /**
4411
+ * Extra Python runtime packages the root metapackage pulls in. `styxkit[all]`
4412
+ * provides the cross-backend runner-selection helpers (`use_docker`, `use_auto`,
4413
+ * ...) that the metapackage's `__init__` re-exports, and transitively installs
4414
+ * every container/graph runner backend. Left unpinned - styxkit's own styxdefs
4415
+ * floor constrains the stack.
4416
+ */
4417
+ const PYTHON_RUNNER_DEPS = ["styxkit[all]"];
4418
+
4419
+ //#endregion
4420
+ //#region src/backend/python/packaging.ts
4421
+ const REQUIRES_PYTHON = ">=3.10";
4422
+ const BUILD_SYSTEM = `[build-system]
4423
+ requires = ["setuptools>=61"]
4424
+ build-backend = "setuptools.build_meta"`;
4425
+ /** Escape a value for embedding in a TOML basic string. */
4426
+ function tomlStr(s) {
4427
+ return s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/[\r\n]+/g, " ").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
4428
+ }
4429
+ /** Suite directory / importable package name; matches the CLI's `pkgDir` fallback. */
4430
+ function pkgDir(pkg) {
4431
+ return pkg.name ?? "package";
4432
+ }
4433
+ /** Distribution (PyPI) name for a package: `<project>_<package>`, or just `<package>`. */
4434
+ function pyDistName(proj, pkg) {
4435
+ const name = pkgDir(pkg);
4436
+ return proj.name ? `${proj.name}_${name}` : name;
4437
+ }
4438
+ const SUMMARY_MAX_LEN = 512;
4439
+ /**
4440
+ * Clamp a description to a <=512-char one-line summary. Prefer cutting at the
4441
+ * last complete sentence that fits (clean, no dangling fragment); if there is no
4442
+ * sentence boundary early enough, cut at a word boundary and mark the elision.
4443
+ */
4444
+ function clampSummary(s) {
4445
+ if (s.length <= SUMMARY_MAX_LEN) return s;
4446
+ const window = s.slice(0, SUMMARY_MAX_LEN);
4447
+ const lastSentence = window.lastIndexOf(". ");
4448
+ if (lastSentence >= 0) return s.slice(0, lastSentence + 1);
4449
+ const ellipsis = "...";
4450
+ const body = window.slice(0, SUMMARY_MAX_LEN - 3);
4451
+ const lastSpace = body.lastIndexOf(" ");
4452
+ return (lastSpace > 0 ? body.slice(0, lastSpace) : body).replace(/[.,;:\s]+$/, "") + ellipsis;
4453
+ }
4454
+ function description$1(doc, fallbackName) {
4455
+ return clampSummary(doc?.description ?? `Styx generated wrappers for ${doc?.title ?? fallbackName ?? "tools"}.`);
4456
+ }
4457
+ function authorsField(doc) {
4458
+ return `[${(doc?.authors?.length ? doc.authors : ["unknown"]).map((a) => `{ name = "${tomlStr(a)}" }`).join(", ")}]`;
4459
+ }
4460
+ function licenseField(proj) {
4461
+ return `{ text = "${tomlStr(proj.license?.description ?? "unknown")}" }`;
4462
+ }
4463
+ /**
4464
+ * Per-suite `pyproject.toml`. The flat layout (`python/<pkg>/bet.py`) makes the
4465
+ * directory itself the importable package, so setuptools' `package-dir` maps the
4466
+ * import name (`<pkg>`) onto the distribution's root directory. The styxdefs
4467
+ * floor is the only runtime dependency.
4468
+ *
4469
+ * Precondition: `pkg.name` must be a valid Python identifier - the flat layout's
4470
+ * relative imports (`from .bet import *`) already require this, and the CLI uses
4471
+ * it verbatim as the directory name, so this stays consistent with that.
4472
+ */
4473
+ function generateSubPyproject(proj, pkg) {
4474
+ const importName = pkgDir(pkg);
4475
+ const cb = new CodeBuilder(" ");
4476
+ cb.line("[project]");
4477
+ cb.line(`name = "${tomlStr(pyDistName(proj, pkg))}"`);
4478
+ cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
4479
+ cb.line(`description = "${tomlStr(description$1(pkg.doc, pkg.name))}"`);
4480
+ cb.line(`readme = "README.md"`);
4481
+ cb.line(`license = ${licenseField(proj)}`);
4482
+ cb.line(`authors = ${authorsField(pkg.doc ?? proj.doc)}`);
4483
+ cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
4484
+ cb.line("dependencies = [");
4485
+ cb.line(` "styxdefs${STYXDEFS_COMPAT.python}",`);
4486
+ cb.line("]");
4487
+ cb.blank();
4488
+ cb.line("[tool.setuptools]");
4489
+ cb.line(`packages = ["${importName}"]`);
4490
+ cb.line(`package-dir = { "${importName}" = "." }`);
4491
+ cb.blank();
4492
+ cb.line("[tool.setuptools.package-data]");
4493
+ cb.line(`"${importName}" = ["py.typed"]`);
4494
+ cb.blank();
4495
+ cb.line(BUILD_SYSTEM);
4496
+ return cb.toString() + "\n";
4497
+ }
4498
+ /**
4499
+ * The metapackage's `__init__.py`. A thin re-export of styxkit so the runner
4500
+ * configuration helpers (`use_docker`, `use_local`, `set_global_runner`, ...)
4501
+ * are reachable as `<project>.<name>` - the v1 ergonomic that the v2 split into
4502
+ * per-suite distributions otherwise dropped. The logic lives in styxkit (pulled
4503
+ * in via `styxkit[all]`), so this stays a wildcard re-export and never re-emits
4504
+ * the runner-config code itself.
4505
+ */
4506
+ function generateRootInitPy() {
4507
+ return "# This file was auto generated by Styx.\n# Do not edit this file directly.\n\n# Re-export styxkit's runner-configuration helpers (use_docker, use_local,\n# use_auto, set_global_runner, get_global_runner, ...) so they are available\n# directly on this package, e.g. `import niwrap; niwrap.use_docker()`.\nfrom styxkit import * # noqa: F401,F403\n";
4508
+ }
4509
+ /**
4510
+ * Root `pyproject.toml`: a metapackage depending on `styxkit[all]` (the runner
4511
+ * stack it re-exports) plus each per-suite distribution. `packages` lists only
4512
+ * the metapackage's own module so setuptools ships the styxkit re-export without
4513
+ * sweeping the sibling suite directories into this distribution.
4514
+ */
4515
+ function generateRootPyproject(proj, distNames, moduleName) {
4516
+ const cb = new CodeBuilder(" ");
4517
+ cb.line("[project]");
4518
+ cb.line(`name = "${tomlStr(proj.name ?? "project")}"`);
4519
+ cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
4520
+ cb.line(`description = "${tomlStr(description$1(proj.doc, proj.name))}"`);
4521
+ cb.line(`readme = "README.md"`);
4522
+ cb.line(`license = ${licenseField(proj)}`);
4523
+ cb.line(`authors = ${authorsField(proj.doc)}`);
4524
+ cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
4525
+ cb.line("dependencies = [");
4526
+ for (const dep of PYTHON_RUNNER_DEPS) cb.line(` "${dep}",`);
4527
+ for (const dist of distNames) cb.line(` "${tomlStr(dist)}",`);
4528
+ cb.line("]");
4529
+ cb.blank();
4530
+ cb.line("[tool.setuptools]");
4531
+ cb.line(`packages = ["${moduleName}"]`);
4532
+ cb.blank();
4533
+ cb.line("[tool.setuptools.package-data]");
4534
+ cb.line(`"${moduleName}" = ["py.typed"]`);
4535
+ cb.blank();
4536
+ cb.line(BUILD_SYSTEM);
4537
+ return cb.toString() + "\n";
4538
+ }
4539
+ /** Per-suite README crediting the upstream tool authors. */
4540
+ function generateSubReadme(proj, pkg) {
4541
+ const projectTitle = proj.doc?.title ?? proj.name ?? "Styx";
4542
+ const packageTitle = pkg.doc?.title ?? pkg.name ?? "package";
4543
+ const url = pkg.doc?.urls?.[0];
4544
+ const titleMd = url ? `[${packageTitle}](${url})` : packageTitle;
4545
+ const credits = pkg.doc?.authors?.length ? pkg.doc.authors.join(", ") : pkg.doc?.urls?.join(", ") ?? "unknown";
4546
+ return `# ${projectTitle} wrappers for ${titleMd}${pkg.doc?.description ? `\n\n${pkg.doc.description}` : ""}\n\n${packageTitle} is made by ${credits}.\n\nThis package contains wrappers only and has no affiliation with the original authors.\n`;
4547
+ }
4548
+ /** Root README listing the bundled per-suite distributions. */
4549
+ function generateRootReadme(proj, distNames) {
4550
+ return `# ${proj.doc?.title ?? proj.name ?? "Styx"}\n${proj.doc?.description ? `\n${proj.doc.description}\n` : ""}\nAuto-generated Styx wrappers. This project bundles the following packages:\n\n${distNames.map((d) => `- ${d}`).join("\n")}\n`;
4551
+ }
4552
+ /** Local-install manifest: each suite directory first, then the root metapackage. */
4553
+ function generateRequirementsTxt(pkgDirs) {
4554
+ return [...pkgDirs.map((d) => `./${d}`), "./"].join("\n") + "\n";
4555
+ }
4556
+
4557
+ //#endregion
4558
+ //#region src/backend/sig-entries.ts
4559
+ /**
4560
+ * Build per-field signature entries for the kwarg wrapper and params factory.
4561
+ * Skips `@type` (the factory injects it as a constant). Required-no-default
4562
+ * entries are placed before defaulted ones so the resulting signature is
4563
+ * syntactically valid in both Python and TS.
4564
+ *
4565
+ * `registerLocal` is called once per field with the wire key; it must return
4566
+ * a scrubbed, unique host identifier (typically by combining a language-aware
4567
+ * scrub function with `Scope.add()`). The caller's scope should already have
4568
+ * the function's other locals (`params`, `runner`, ...) reserved so this
4569
+ * registration cannot collide with them.
4352
4570
  */
4353
- function emitKwargWrapper$1(ctx, entries, funcName, paramsFnName, executeFnName, outputsType, cb) {
4354
- cb.line(`def ${funcName}(`);
4355
- cb.indent(() => {
4356
- emitSigParams$1(entries, cb);
4357
- cb.line("runner: Runner | None = None,");
4358
- });
4359
- const returnType = outputsType ?? "None";
4360
- cb.line(`) -> ${returnType}:`);
4361
- cb.indent(() => {
4362
- const appDoc = ctx.app?.doc;
4363
- cb.line("\"\"\"");
4364
- if (appDoc?.title) cb.line(appDoc.title);
4365
- if (appDoc?.description) {
4366
- if (appDoc?.title) cb.blank();
4367
- cb.line(appDoc.description);
4368
- }
4369
- if (appDoc?.authors?.length) {
4370
- cb.blank();
4371
- cb.line(`Author: ${appDoc.authors.join(", ")}`);
4372
- }
4373
- if (appDoc?.urls?.length) {
4374
- cb.blank();
4375
- cb.line(`URL: ${appDoc.urls[0]}`);
4376
- }
4377
- cb.blank();
4378
- emitArgsBlock([...entries, {
4379
- name: "runner",
4380
- doc: "Command runner (defaults to global runner)."
4381
- }], cb);
4382
- cb.blank();
4383
- cb.line("Returns:");
4384
- cb.line(outputsType ? " Tool outputs (paths to files produced by the tool)." : " None.");
4385
- cb.line("\"\"\"");
4386
- if (entries.length === 0) cb.line(`params = ${paramsFnName}()`);
4387
- else {
4388
- cb.line(`params = ${paramsFnName}(`);
4389
- cb.indent(() => {
4390
- for (const e of entries) cb.line(`${e.name}=${e.name},`);
4391
- });
4392
- cb.line(")");
4393
- }
4394
- if (outputsType) cb.line(`return ${executeFnName}(params, runner)`);
4395
- else cb.line(`${executeFnName}(params, runner)`);
4396
- });
4571
+ function buildSigEntries(rootType, fieldInfo, registerLocal, opts) {
4572
+ const entries = [];
4573
+ for (const [fieldName, fieldType] of Object.entries(rootType.fields)) {
4574
+ if (fieldType.kind === "literal") continue;
4575
+ const fi = fieldInfo.get(fieldName);
4576
+ const isOptional = fieldType.kind === "optional";
4577
+ const inner = isOptional ? fieldType.inner : fieldType;
4578
+ let sigType = opts.renderType(inner);
4579
+ if (isOptional) sigType += opts.nullableSuffix;
4580
+ let sigDefault;
4581
+ const hasExplicitDefault = fi?.defaultValue !== void 0;
4582
+ if (hasExplicitDefault) sigDefault = opts.renderDefault(fi.defaultValue);
4583
+ else if (isOptional) sigDefault = opts.nullableDefault;
4584
+ entries.push({
4585
+ name: registerLocal(fieldName),
4586
+ wireKey: fieldName,
4587
+ sigType,
4588
+ sigDefault,
4589
+ isOptional,
4590
+ hasExplicitDefault,
4591
+ doc: fi?.doc
4592
+ });
4593
+ }
4594
+ const required = entries.filter((e) => e.sigDefault === void 0);
4595
+ const defaulted = entries.filter((e) => e.sigDefault !== void 0);
4596
+ return [...required, ...defaulted];
4397
4597
  }
4398
4598
 
4399
4599
  //#endregion
@@ -5157,7 +5357,7 @@ function computePublicNames$1(appId) {
5157
5357
  * (the `reg` registrations and the `sigScope` child), so passing the same scope
5158
5358
  * the emitter continues with keeps later local registrations consistent.
5159
5359
  */
5160
- function buildEmitModel$1(ctx, scope = new Scope(PY_RESERVED)) {
5360
+ function buildEmitModel(ctx, scope = new Scope(PY_RESERVED)) {
5161
5361
  const appId = ctx.app?.id;
5162
5362
  const pkg = ctx.package?.name;
5163
5363
  const publicNames = computePublicNames$1(appId);
@@ -5200,7 +5400,7 @@ function buildEmitModel$1(ctx, scope = new Scope(PY_RESERVED)) {
5200
5400
  function generatePython(ctx, packageScope) {
5201
5401
  const cb = new CodeBuilder(" ");
5202
5402
  const scope = packageScope ?? new Scope(PY_RESERVED);
5203
- const { names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel$1(ctx, scope);
5403
+ const { names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel(ctx, scope);
5204
5404
  cb.comment("This file was auto generated by Styx.", "# ");
5205
5405
  cb.comment("Do not edit this file directly.", "# ");
5206
5406
  cb.blank();
@@ -5347,6 +5547,7 @@ var PythonBackend = class {
5347
5547
  const files = /* @__PURE__ */ new Map();
5348
5548
  const distNames = [];
5349
5549
  const pkgDirs = [];
5550
+ const warnings = [];
5350
5551
  for (const p of packages) {
5351
5552
  const pkg = p.meta ?? {};
5352
5553
  const dir = pkg.name ?? "package";
@@ -5355,13 +5556,21 @@ var PythonBackend = class {
5355
5556
  files.set(`${dir}/pyproject.toml`, generateSubPyproject(proj, pkg));
5356
5557
  files.set(`${dir}/README.md`, generateSubReadme(proj, pkg));
5357
5558
  }
5358
- files.set("pyproject.toml", generateRootPyproject(proj, distNames));
5559
+ let rootMod = pyScrubIdent(proj.name && proj.name.trim() ? proj.name : "project", PY_RESERVED);
5560
+ if (pkgDirs.includes(rootMod)) {
5561
+ const collided = rootMod;
5562
+ while (pkgDirs.includes(rootMod)) rootMod += "_";
5563
+ warnings.push({ message: `metapackage module "${collided}" collides with a suite directory; emitting as "${rootMod}" instead` });
5564
+ }
5565
+ files.set(`${rootMod}/__init__.py`, generateRootInitPy());
5566
+ files.set(`${rootMod}/py.typed`, "");
5567
+ files.set("pyproject.toml", generateRootPyproject(proj, distNames, rootMod));
5359
5568
  files.set("README.md", generateRootReadme(proj, distNames));
5360
5569
  files.set("requirements.txt", generateRequirementsTxt(pkgDirs));
5361
5570
  return {
5362
5571
  files,
5363
5572
  errors: [],
5364
- warnings: []
5573
+ warnings
5365
5574
  };
5366
5575
  }
5367
5576
  };
@@ -5507,7 +5716,7 @@ const pyDialect = {
5507
5716
  * @param opts - Import and package-root options.
5508
5717
  */
5509
5718
  function renderPythonCall(ctx, config, opts = {}) {
5510
- const model = buildEmitModel$1(ctx);
5719
+ const model = buildEmitModel(ctx);
5511
5720
  const pkg = ctx.package?.name;
5512
5721
  const callee = pkg ? `${pkg}.${model.names.wrapper}` : model.names.wrapper;
5513
5722
  const call = model.rootIsStruct && model.rootType.kind === "struct" ? renderKwargCall(callee, model.sigEntries, model.rootType, config) : `${callee}(${renderValue(config, model.rootType, "", pyDialect)})`;
@@ -5530,6 +5739,495 @@ function renderKwargCall(callee, sigEntries, rootType, config) {
5530
5739
  return `${callee}(\n${lines.join("\n")}\n)`;
5531
5740
  }
5532
5741
 
5742
+ //#endregion
5743
+ //#region src/backend/typed-spec.ts
5744
+ /** Project a tool's solved tree into the flat typed spec for delegation backends. */
5745
+ function buildTypedSpec(ctx) {
5746
+ const model = buildEmitModel(ctx);
5747
+ const delegation = {
5748
+ moduleName: appModuleName$1(ctx.app),
5749
+ wrapperFn: model.names.wrapper,
5750
+ outputsClass: model.names.outputs
5751
+ };
5752
+ const outputs = collectOutputFields(ctx, pyId);
5753
+ const streams = streamFields(ctx, pyId);
5754
+ if (!model.rootIsStruct || model.rootType.kind !== "struct") return {
5755
+ rootIsStruct: false,
5756
+ params: [],
5757
+ outputs,
5758
+ streams,
5759
+ delegation
5760
+ };
5761
+ const rootType = model.rootType;
5762
+ const fieldByName = new Map(structFields(ctx, rootType, ctx.expr).map((f) => [f.name, f]));
5763
+ const fieldInfo = collectFieldInfo(ctx, rootType);
5764
+ return {
5765
+ rootIsStruct: true,
5766
+ params: model.sigEntries.map((e) => {
5767
+ const fe = fieldByName.get(e.wireKey);
5768
+ const fi = fieldInfo.get(e.wireKey);
5769
+ return describeParam(e, fe?.type, fe?.node, fi?.defaultValue, fi?.doc);
5770
+ }),
5771
+ outputs,
5772
+ streams,
5773
+ delegation
5774
+ };
5775
+ }
5776
+ function describeParam(e, type, node, defaultValue, fallbackDoc) {
5777
+ const optional = e.isOptional;
5778
+ const hasDefault = e.hasExplicitDefault;
5779
+ const base = {
5780
+ hostName: e.name,
5781
+ wireKey: e.wireKey,
5782
+ optional,
5783
+ hasDefault,
5784
+ mandatory: !optional && !hasDefault,
5785
+ default: defaultValue,
5786
+ doc: e.doc ?? fallbackDoc
5787
+ };
5788
+ const inner = type && type.kind === "optional" ? type.inner : type;
5789
+ const choices = inner ? literalChoices(inner, node) : void 0;
5790
+ if (choices) return {
5791
+ ...base,
5792
+ kind: "enum",
5793
+ choices
5794
+ };
5795
+ switch (inner?.kind) {
5796
+ case "scalar":
5797
+ if (inner.scalar === "path") return {
5798
+ ...base,
5799
+ kind: "path",
5800
+ scalarKind: "path",
5801
+ mediaTypes: pathMediaTypes(node)
5802
+ };
5803
+ if (inner.scalar === "int" || inner.scalar === "float") return {
5804
+ ...base,
5805
+ kind: inner.scalar,
5806
+ scalarKind: inner.scalar,
5807
+ range: numericRange(node)
5808
+ };
5809
+ return {
5810
+ ...base,
5811
+ kind: "str",
5812
+ scalarKind: "str"
5813
+ };
5814
+ case "bool": return {
5815
+ ...base,
5816
+ kind: "bool"
5817
+ };
5818
+ case "count": return {
5819
+ ...base,
5820
+ kind: "count"
5821
+ };
5822
+ case "list": return {
5823
+ ...base,
5824
+ kind: "list",
5825
+ listBounds: listBounds(node),
5826
+ itemType: describeItem(inner.item, node)
5827
+ };
5828
+ case "struct": return {
5829
+ ...base,
5830
+ kind: "struct"
5831
+ };
5832
+ case "union": return {
5833
+ ...base,
5834
+ kind: "union"
5835
+ };
5836
+ default: return {
5837
+ ...base,
5838
+ kind: "str"
5839
+ };
5840
+ }
5841
+ }
5842
+ function describeItem(item, node) {
5843
+ const inner = item.kind === "optional" ? item.inner : item;
5844
+ const choices = literalChoices(inner, node);
5845
+ if (choices) return {
5846
+ kind: "enum",
5847
+ choices
5848
+ };
5849
+ switch (inner.kind) {
5850
+ case "scalar":
5851
+ if (inner.scalar === "path") return {
5852
+ kind: "path",
5853
+ scalarKind: "path",
5854
+ mediaTypes: pathMediaTypes(node)
5855
+ };
5856
+ return {
5857
+ kind: inner.scalar,
5858
+ scalarKind: inner.scalar
5859
+ };
5860
+ case "bool": return { kind: "bool" };
5861
+ case "count": return { kind: "count" };
5862
+ case "struct": return { kind: "struct" };
5863
+ case "union": return { kind: "union" };
5864
+ default: return { kind: "str" };
5865
+ }
5866
+ }
5867
+ /** Allowed values when `inner` is a literal or a union of literals (else undefined). */
5868
+ function literalChoices(inner, node) {
5869
+ if (inner.kind === "literal") return [inner.value];
5870
+ if (inner.kind === "union" && inner.variants.length > 0 && inner.variants.every((v) => v.type.kind === "literal")) return inner.variants.map((v) => v.type.value);
5871
+ const altNode = node ? findNode(node, (n) => n.kind === "alternative") : void 0;
5872
+ if (altNode && altNode.kind === "alternative") {
5873
+ const alts = altNode.attrs.alts;
5874
+ if (alts.length > 0 && alts.every((a) => a.kind === "literal")) return alts.map((a) => a.attrs.str);
5875
+ }
5876
+ }
5877
+ function numericRange(node) {
5878
+ const r = findRangeNode(node);
5879
+ if (!r) return void 0;
5880
+ const { minValue, maxValue } = r.attrs;
5881
+ if (minValue === void 0 && maxValue === void 0) return void 0;
5882
+ return {
5883
+ min: minValue,
5884
+ max: maxValue
5885
+ };
5886
+ }
5887
+ function listBounds(node) {
5888
+ const r = findRepeatNode(node);
5889
+ if (!r) return void 0;
5890
+ const { countMin, countMax } = r.attrs;
5891
+ if (countMin === void 0 && countMax === void 0) return void 0;
5892
+ return {
5893
+ min: countMin,
5894
+ max: countMax
5895
+ };
5896
+ }
5897
+ function pathMediaTypes(node) {
5898
+ const p = node ? findNode(node, (n) => n.kind === "path") : void 0;
5899
+ if (p && p.kind === "path") {
5900
+ const mt = p.attrs.mediaTypes;
5901
+ if (mt && mt.length > 0) return mt;
5902
+ }
5903
+ }
5904
+
5905
+ //#endregion
5906
+ //#region src/backend/nipype/nipype.ts
5907
+ /** Derive the per-tool nipype module/class names. */
5908
+ function nipypeNames(ctx) {
5909
+ const mod = appModuleName$1(ctx.app);
5910
+ const rawId = ctx.app?.id ?? "tool";
5911
+ const cls = pascalCase(/^[0-9]/.test(rawId) ? "v_" + rawId : rawId);
5912
+ return {
5913
+ styxStem: `_${mod}`,
5914
+ ifaceStem: mod,
5915
+ cls,
5916
+ inputSpec: `${cls}InputSpec`,
5917
+ outputSpec: `${cls}OutputSpec`
5918
+ };
5919
+ }
5920
+ /** Generate the nipype interface module source for one tool. */
5921
+ function generateNipype(ctx) {
5922
+ return emitNipypeInterface(ctx, buildTypedSpec(ctx), nipypeNames(ctx));
5923
+ }
5924
+ /**
5925
+ * Emits nipype `Interface` definitions whose typed InputSpec/OutputSpec carry
5926
+ * rich constraints (numeric ranges, list bounds, enum choices, file types) and
5927
+ * which delegate execution to the styx Python wrapper (Option B): no command-line
5928
+ * arg-building or output-path resolution is re-implemented here.
5929
+ *
5930
+ * Per tool, two co-located files are emitted so the output is a self-contained,
5931
+ * importable Python package: `_<tool>.py` (the styx Python module) and
5932
+ * `<tool>.py` (the interface, importing the wrapper via a relative import).
5933
+ */
5934
+ var NipypeBackend = class {
5935
+ name = "nipype";
5936
+ target = "nipype";
5937
+ emitApp(ctx, _scope) {
5938
+ const names = nipypeNames(ctx);
5939
+ const spec = buildTypedSpec(ctx);
5940
+ const warnings = [];
5941
+ if (!spec.rootIsStruct) warnings.push({ message: `nipype: '${ctx.app?.id ?? "?"}' has a non-struct root; emitted interface has no typed inputs and raises on run.` });
5942
+ const styxCode = generatePython(ctx);
5943
+ const ifaceCode = emitNipypeInterface(ctx, spec, names);
5944
+ return {
5945
+ meta: ctx.app,
5946
+ files: new Map([[`${names.styxStem}.py`, styxCode], [`${names.ifaceStem}.py`, ifaceCode]]),
5947
+ errors: [],
5948
+ warnings
5949
+ };
5950
+ }
5951
+ };
5952
+
5953
+ //#endregion
5954
+ //#region src/backend/pydra/emit.ts
5955
+ /** A scalar enum's Python type: int when every choice is numeric, else str. */
5956
+ function enumScalar(choices) {
5957
+ return (choices ?? []).every((c) => typeof c === "number") ? "int" : "str";
5958
+ }
5959
+ /** True when a parameter is a file path (a path scalar or a list of paths). */
5960
+ function isPathParam(p) {
5961
+ return p.kind === "path" || p.kind === "list" && p.itemType?.kind === "path";
5962
+ }
5963
+ function itemTypeStr(item, imp) {
5964
+ if (!item) {
5965
+ imp.typing = true;
5966
+ return "ty.Any";
5967
+ }
5968
+ switch (item.kind) {
5969
+ case "path":
5970
+ imp.file = true;
5971
+ return "File";
5972
+ case "int":
5973
+ case "count": return "int";
5974
+ case "float": return "float";
5975
+ case "str": return "str";
5976
+ case "bool": return "bool";
5977
+ case "enum": return enumScalar(item.choices);
5978
+ default:
5979
+ imp.typing = true;
5980
+ return "ty.Any";
5981
+ }
5982
+ }
5983
+ /** Base (non-optional) Python type expression for a parameter. */
5984
+ function baseType(p, imp) {
5985
+ switch (p.kind) {
5986
+ case "path":
5987
+ imp.file = true;
5988
+ return "File";
5989
+ case "int":
5990
+ case "count": return "int";
5991
+ case "float": return "float";
5992
+ case "str": return "str";
5993
+ case "bool": return "bool";
5994
+ case "enum": return enumScalar(p.choices);
5995
+ case "list": return `list[${itemTypeStr(p.itemType, imp)}]`;
5996
+ case "struct":
5997
+ case "union":
5998
+ imp.typing = true;
5999
+ return "ty.Any";
6000
+ }
6001
+ }
6002
+ /** Full input type, wrapping in `ty.Optional[...]` for an omittable-no-default field. */
6003
+ function inputType(p, imp) {
6004
+ const base = baseType(p, imp);
6005
+ if (p.optional && !p.hasDefault) {
6006
+ imp.typing = true;
6007
+ return `ty.Optional[${base}]`;
6008
+ }
6009
+ return base;
6010
+ }
6011
+ /** An attrs validator enforcing numeric range / list-length bounds, or undefined. */
6012
+ function validatorExpr(p, imp) {
6013
+ const checks = [];
6014
+ if (p.range) {
6015
+ if (p.range.min !== void 0) checks.push(`attrs.validators.ge(${renderPyLiteral(p.range.min)})`);
6016
+ if (p.range.max !== void 0) checks.push(`attrs.validators.le(${renderPyLiteral(p.range.max)})`);
6017
+ }
6018
+ if (p.listBounds) {
6019
+ if (p.listBounds.min !== void 0) checks.push(`attrs.validators.min_len(${p.listBounds.min})`);
6020
+ if (p.listBounds.max !== void 0) checks.push(`attrs.validators.max_len(${p.listBounds.max})`);
6021
+ }
6022
+ if (checks.length === 0) return void 0;
6023
+ imp.attrs = true;
6024
+ return `_styx_optional(${checks.length === 1 ? checks[0] : `attrs.validators.and_(${checks.join(", ")})`})`;
6025
+ }
6026
+ /** Help text: doc + degrade / media-type notes. */
6027
+ function helpText(p) {
6028
+ const parts = [];
6029
+ if (p.doc) parts.push(p.doc);
6030
+ if (p.kind === "struct" || p.kind === "union") parts.push("(nested configuration; pass a dict)");
6031
+ if (p.mediaTypes && p.mediaTypes.length > 0) parts.push(`(media types: ${p.mediaTypes.join(", ")})`);
6032
+ return parts.length > 0 ? parts.join(" ") : void 0;
6033
+ }
6034
+ /** Render a `python.arg(...)` field for one parameter. */
6035
+ function renderInputArg(p, imp) {
6036
+ const args = [`type=${inputType(p, imp)}`];
6037
+ if (p.mandatory) {} else if (p.optional && !p.hasDefault) args.push("default=None");
6038
+ else args.push(`default=${renderPyLiteral(p.default)}`);
6039
+ if (p.kind === "enum") args.push(`allowed_values=[${(p.choices ?? []).map((c) => renderPyLiteral(c)).join(", ")}]`);
6040
+ const v = validatorExpr(p, imp);
6041
+ if (v) args.push(`validator=${v}`);
6042
+ const h = helpText(p);
6043
+ if (h) args.push(`help=${pyStr(h)}`);
6044
+ return `python.arg(${args.join(", ")})`;
6045
+ }
6046
+ /** Python type for an output field. `isRoot` is the synthetic output directory. */
6047
+ function outputType(f, isRoot, imp) {
6048
+ if (f.shape.kind === "list") {
6049
+ imp.file = true;
6050
+ return "list[File]";
6051
+ }
6052
+ if (isRoot) {
6053
+ imp.directory = true;
6054
+ return "Directory";
6055
+ }
6056
+ imp.file = true;
6057
+ if (f.shape.optional) {
6058
+ imp.typing = true;
6059
+ return "ty.Optional[File]";
6060
+ }
6061
+ return "File";
6062
+ }
6063
+ function renderOutputArg(f, isRoot, imp) {
6064
+ const args = [`type=${outputType(f, isRoot, imp)}`];
6065
+ if (f.doc) args.push(`help=${pyStr(f.doc)}`);
6066
+ return `python.out(${args.join(", ")})`;
6067
+ }
6068
+ /**
6069
+ * Emit the pydra task module for one tool: a `@python.define` task whose typed
6070
+ * inputs/outputs carry rich constraints and whose body delegates execution to the
6071
+ * co-emitted styx Python wrapper (Option B). Targets the post-rewrite
6072
+ * `pydra.compose` API (pydra >= 1.0a, Python >= 3.11).
6073
+ */
6074
+ function emitPydraTask(ctx, spec, names) {
6075
+ const imp = {
6076
+ typing: false,
6077
+ attrs: false,
6078
+ file: false,
6079
+ directory: false
6080
+ };
6081
+ const inputEntries = spec.params.map((p) => ({
6082
+ key: p.hostName,
6083
+ expr: renderInputArg(p, imp)
6084
+ }));
6085
+ const outputEntries = spec.outputs.map((f, i) => ({
6086
+ key: f.id,
6087
+ expr: renderOutputArg(f, i === 0, imp)
6088
+ }));
6089
+ for (const s of spec.streams) outputEntries.push({
6090
+ key: s.id,
6091
+ expr: `python.out(type=list[str]${s.doc ? `, help=${pyStr(s.doc)}` : ""})`
6092
+ });
6093
+ const needsPath = spec.params.some(isPathParam);
6094
+ const cb = new CodeBuilder(" ");
6095
+ cb.comment("This file was auto generated by Styx.", "# ");
6096
+ cb.comment("Do not edit this file directly.", "# ");
6097
+ cb.comment("Targets the pydra.compose API (pydra >= 1.0a, Python >= 3.11).", "# ");
6098
+ cb.blank();
6099
+ if (needsPath) cb.line("import os");
6100
+ if (imp.typing) cb.line("import typing as ty");
6101
+ if (imp.attrs) cb.line("import attrs.validators");
6102
+ cb.line("from pydra.compose import python");
6103
+ const ff = [];
6104
+ if (imp.directory) ff.push("Directory");
6105
+ if (imp.file) ff.push("File");
6106
+ if (ff.length > 0) cb.line(`from fileformats.generic import ${ff.join(", ")}`);
6107
+ cb.blank();
6108
+ cb.line(`from .${names.styxStem} import ${spec.delegation.wrapperFn}`);
6109
+ cb.blank();
6110
+ cb.blank();
6111
+ if (imp.attrs) {
6112
+ cb.line("def _styx_optional(validator):");
6113
+ cb.indent(() => {
6114
+ cb.line("\"\"\"Apply an attrs validator only to a set (non-NOTHING), non-None value.\"\"\"");
6115
+ cb.line("def _check(instance, attribute, value):");
6116
+ cb.indent(() => {
6117
+ cb.line("if value is attrs.NOTHING or value is None:");
6118
+ cb.indent(() => cb.line("return"));
6119
+ cb.line("validator(instance, attribute, value)");
6120
+ });
6121
+ cb.line("return _check");
6122
+ });
6123
+ cb.blank();
6124
+ cb.blank();
6125
+ }
6126
+ if (needsPath) {
6127
+ cb.line("def _styx_path(value):");
6128
+ cb.indent(() => {
6129
+ cb.line("\"\"\"Convert fileformats / Path inputs into a styxdefs-accepted path.\"\"\"");
6130
+ cb.line("if value is None:");
6131
+ cb.indent(() => cb.line("return None"));
6132
+ cb.line("if isinstance(value, (list, tuple)):");
6133
+ cb.indent(() => cb.line("return [os.fspath(v) for v in value]"));
6134
+ cb.line("return os.fspath(value)");
6135
+ });
6136
+ cb.blank();
6137
+ cb.blank();
6138
+ }
6139
+ cb.line("@python.define(");
6140
+ cb.indent(() => {
6141
+ if (inputEntries.length === 0) cb.line("inputs={},");
6142
+ else {
6143
+ cb.line("inputs={");
6144
+ cb.indent(() => {
6145
+ for (const e of inputEntries) cb.line(`${pyStr(e.key)}: ${e.expr},`);
6146
+ });
6147
+ cb.line("},");
6148
+ }
6149
+ cb.line("outputs={");
6150
+ cb.indent(() => {
6151
+ for (const e of outputEntries) cb.line(`${pyStr(e.key)}: ${e.expr},`);
6152
+ });
6153
+ cb.line("},");
6154
+ });
6155
+ cb.line(")");
6156
+ const paramList = spec.params.map((p) => p.hostName);
6157
+ cb.line(`def ${names.cls}(${paramList.join(", ")}):`);
6158
+ cb.indent(() => {
6159
+ emitDocstring(cb, [ctx.app?.doc?.title, ctx.app?.doc?.description].filter(Boolean).join("\n\n") || void 0);
6160
+ if (!spec.rootIsStruct) {
6161
+ cb.line("raise NotImplementedError(");
6162
+ cb.indent(() => cb.line(pyStr("styx pydra backend: tools with a non-struct root are not supported.")));
6163
+ cb.line(")");
6164
+ return;
6165
+ }
6166
+ if (spec.params.length === 0) cb.line(`result = ${spec.delegation.wrapperFn}()`);
6167
+ else {
6168
+ cb.line(`result = ${spec.delegation.wrapperFn}(`);
6169
+ cb.indent(() => {
6170
+ for (const p of spec.params) {
6171
+ const expr = isPathParam(p) ? `_styx_path(${p.hostName})` : p.hostName;
6172
+ cb.line(`${p.hostName}=${expr},`);
6173
+ }
6174
+ });
6175
+ cb.line(")");
6176
+ }
6177
+ const returns = [...spec.outputs.map((f) => `result.${f.id}`), ...spec.streams.map((s) => `result.${s.id}`)];
6178
+ if (returns.length === 1) cb.line(`return ${returns[0]}`);
6179
+ else cb.line(`return ${returns.join(", ")}`);
6180
+ });
6181
+ return cb.toString();
6182
+ }
6183
+
6184
+ //#endregion
6185
+ //#region src/backend/pydra/pydra.ts
6186
+ /** Derive the per-tool pydra module/class names. */
6187
+ function pydraNames(ctx) {
6188
+ const mod = appModuleName$1(ctx.app);
6189
+ const rawId = ctx.app?.id ?? "tool";
6190
+ const safeId = /^[0-9]/.test(rawId) ? "v_" + rawId : rawId;
6191
+ return {
6192
+ styxStem: `_${mod}`,
6193
+ ifaceStem: mod,
6194
+ cls: pascalCase(safeId)
6195
+ };
6196
+ }
6197
+ /** Generate the pydra task module source for one tool. */
6198
+ function generatePydra(ctx) {
6199
+ return emitPydraTask(ctx, buildTypedSpec(ctx), pydraNames(ctx));
6200
+ }
6201
+ /**
6202
+ * Emits pydra tasks (`@python.define`, the post-rewrite pydra.compose API) whose
6203
+ * typed inputs/outputs carry rich constraints (numeric ranges and list bounds via
6204
+ * attrs validators, enum choices, file types, defaults) and which delegate
6205
+ * execution to the styx Python wrapper (Option B): no command-line arg-building
6206
+ * or output-path resolution is re-implemented here.
6207
+ *
6208
+ * Per tool, two co-located files are emitted so the output is a self-contained,
6209
+ * importable Python package: `_<tool>.py` (the styx Python module) and
6210
+ * `<tool>.py` (the task, importing the wrapper via a relative import).
6211
+ */
6212
+ var PydraBackend = class {
6213
+ name = "pydra";
6214
+ target = "pydra";
6215
+ emitApp(ctx, _scope) {
6216
+ const names = pydraNames(ctx);
6217
+ const spec = buildTypedSpec(ctx);
6218
+ const warnings = [];
6219
+ if (!spec.rootIsStruct) warnings.push({ message: `pydra: '${ctx.app?.id ?? "?"}' has a non-struct root; emitted task has no typed inputs and raises on run.` });
6220
+ const styxCode = generatePython(ctx);
6221
+ const taskCode = emitPydraTask(ctx, spec, names);
6222
+ return {
6223
+ meta: ctx.app,
6224
+ files: new Map([[`${names.styxStem}.py`, styxCode], [`${names.ifaceStem}.py`, taskCode]]),
6225
+ errors: [],
6226
+ warnings
6227
+ };
6228
+ }
6229
+ };
6230
+
5533
6231
  //#endregion
5534
6232
  //#region src/backend/schema/jsonschema.ts
5535
6233
  var SchemaBuilder = class {
@@ -5575,7 +6273,7 @@ var SchemaBuilder = class {
5575
6273
  case "literal": return { const: type.value };
5576
6274
  case "optional": return this.fromType(type.inner, node?.kind === "optional" ? node.attrs.node : void 0);
5577
6275
  case "list": {
5578
- const repeat = node?.kind === "repeat" ? node : void 0;
6276
+ const repeat = findRepeatNode(node);
5579
6277
  const schema = {
5580
6278
  type: "array",
5581
6279
  items: this.fromType(type.item, repeat?.attrs.node)
@@ -5585,7 +6283,7 @@ var SchemaBuilder = class {
5585
6283
  return schema;
5586
6284
  }
5587
6285
  case "struct": return this.structSchema(type, node);
5588
- case "union": return this.unionSchema(type);
6286
+ case "union": return this.unionSchema(type, node);
5589
6287
  }
5590
6288
  }
5591
6289
  findTerminal(node) {
@@ -5646,9 +6344,20 @@ var SchemaBuilder = class {
5646
6344
  if (required.length > 0) schema.required = required;
5647
6345
  return schema;
5648
6346
  }
5649
- unionSchema(type) {
6347
+ unionSchema(type, node) {
5650
6348
  if (type.variants.every((v) => v.type.kind === "literal")) return { enum: type.variants.map((v) => v.type.kind === "literal" ? v.type.value : "") };
5651
- return { oneOf: type.variants.map((v) => this.fromType(v.type)) };
6349
+ const altNode = findAlternativeNode(node);
6350
+ return { oneOf: type.variants.map((v, i) => {
6351
+ const schema = this.fromType(v.type, altNode?.attrs.alts[i]);
6352
+ if (v.type.kind === "struct" && "@type" in v.type.fields && schema.properties && !("@type" in schema.properties)) {
6353
+ schema.properties = {
6354
+ "@type": this.fromType(v.type.fields["@type"]),
6355
+ ...schema.properties
6356
+ };
6357
+ schema.required = ["@type", ...schema.required ?? []];
6358
+ }
6359
+ return schema;
6360
+ }) };
5652
6361
  }
5653
6362
  };
5654
6363
  function generateSchema(ctx) {
@@ -6900,7 +7609,7 @@ function computePublicNames(appId) {
6900
7609
  * (the `reg` registrations and the `sigScope` child), so passing the same scope
6901
7610
  * the emitter continues with keeps later local registrations consistent.
6902
7611
  */
6903
- function buildEmitModel(ctx, scope = new Scope(TS_RESERVED)) {
7612
+ function buildEmitModel$1(ctx, scope = new Scope(TS_RESERVED)) {
6904
7613
  const appId = ctx.app?.id;
6905
7614
  const pkg = ctx.package?.name ?? "unknown";
6906
7615
  const publicNames = computePublicNames(appId);
@@ -6943,7 +7652,7 @@ function buildEmitModel(ctx, scope = new Scope(TS_RESERVED)) {
6943
7652
  function generateTypeScript(ctx, packageScope) {
6944
7653
  const cb = new CodeBuilder(" ");
6945
7654
  const scope = packageScope ?? new Scope(TS_RESERVED);
6946
- const { appId, pkg, names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel(ctx, scope);
7655
+ const { appId, pkg, names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel$1(ctx, scope);
6947
7656
  cb.comment("This file was auto generated by Styx.");
6948
7657
  cb.comment("Do not edit this file directly.");
6949
7658
  cb.blank();
@@ -7126,7 +7835,7 @@ const tsDialect = {
7126
7835
  * @param opts - Import and package-root options.
7127
7836
  */
7128
7837
  function renderTypeScriptCall(ctx, config, opts = {}) {
7129
- const model = buildEmitModel(ctx);
7838
+ const model = buildEmitModel$1(ctx);
7130
7839
  const pkg = ctx.package?.name;
7131
7840
  const fnName = model.rootIsStruct ? model.names.execute : model.names.wrapper;
7132
7841
  const call = `${pkg ? `${pkg}.${fnName}` : fnName}(${model.rootIsStruct && model.rootType.kind === "struct" ? renderStructLiteral(config, model.rootType, "", tsDialect, model.rootTypeTag) : renderValue(config, model.rootType, "", tsDialect)})`;
@@ -8291,8 +9000,10 @@ function compile(source, filenameOrOptions) {
8291
9000
  exports.BoutiquesBackend = BoutiquesBackend;
8292
9001
  exports.CodeBuilder = CodeBuilder;
8293
9002
  exports.JsonSchemaBackend = JsonSchemaBackend;
9003
+ exports.NipypeBackend = NipypeBackend;
8294
9004
  exports.PYTHON_RUNNER_DEPS = PYTHON_RUNNER_DEPS;
8295
9005
  exports.PassStatus = PassStatus;
9006
+ exports.PydraBackend = PydraBackend;
8296
9007
  exports.PythonBackend = PythonBackend;
8297
9008
  exports.STYXDEFS_COMPAT = STYXDEFS_COMPAT;
8298
9009
  exports.Scope = Scope;
@@ -8300,7 +9011,9 @@ exports.TypeScriptBackend = TypeScriptBackend;
8300
9011
  exports.alt = alt;
8301
9012
  exports.appEntrypoint = appEntrypoint;
8302
9013
  exports.atomKey = atomKey;
9014
+ exports.buildEmitModel = buildEmitModel;
8303
9015
  exports.buildSigEntries = buildSigEntries;
9016
+ exports.buildTypedSpec = buildTypedSpec;
8304
9017
  exports.camelCase = camelCase;
8305
9018
  exports.canonicalize = canonicalize;
8306
9019
  exports.collectFieldInfo = collectFieldInfo;
@@ -8323,7 +9036,9 @@ exports.float = float;
8323
9036
  exports.format = format;
8324
9037
  exports.formatSolveResult = formatSolveResult;
8325
9038
  exports.generateBoutiques = generateBoutiques;
9039
+ exports.generateNipype = generateNipype;
8326
9040
  exports.generateOutputsSchema = generateOutputsSchema;
9041
+ exports.generatePydra = generatePydra;
8327
9042
  exports.generatePython = generatePython;
8328
9043
  exports.generateSchema = generateSchema;
8329
9044
  exports.generateTypeScript = generateTypeScript;
@@ -8333,6 +9048,7 @@ exports.isIterated = isIterated;
8333
9048
  exports.isStructural = isStructural;
8334
9049
  exports.isTerminal = isTerminal;
8335
9050
  exports.lit = lit;
9051
+ exports.nipypeNames = nipypeNames;
8336
9052
  exports.nodeRef = nodeRef;
8337
9053
  exports.opt = opt;
8338
9054
  exports.outputGate = outputGate;
@@ -8340,6 +9056,7 @@ exports.pascalCase = pascalCase;
8340
9056
  exports.path = path;
8341
9057
  exports.planOutput = planOutput;
8342
9058
  exports.planScope = planScope;
9059
+ exports.pydraNames = pydraNames;
8343
9060
  exports.removeEmpty = removeEmpty;
8344
9061
  exports.renderPythonCall = renderPythonCall;
8345
9062
  exports.renderStructLiteral = renderStructLiteral;