@styx-api/core 0.4.0 → 0.5.1
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 +978 -250
- package/dist/index.d.cts +226 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +226 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +971 -251
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/backend/index.ts +13 -1
- package/src/backend/nipype/emit.ts +270 -0
- package/src/backend/nipype/index.ts +3 -0
- package/src/backend/nipype/nipype.ts +70 -0
- package/src/backend/pydra/emit.ts +318 -0
- package/src/backend/pydra/index.ts +3 -0
- package/src/backend/pydra/pydra.ts +67 -0
- package/src/backend/python/index.ts +6 -1
- package/src/backend/python/outputs-emit.ts +1 -1
- package/src/backend/python/packaging.ts +56 -13
- package/src/backend/python/python.ts +41 -4
- package/src/backend/schema/jsonschema.ts +34 -4
- package/src/backend/styxdefs-compat.ts +6 -4
- package/src/backend/typed-spec.ts +258 -0
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,452 @@ 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 suite source stays in a flat directory
|
|
4465
|
+
* (`python/<pkg>/bet.py`), but setuptools' `package-dir` maps that directory onto
|
|
4466
|
+
* the dotted import package `<project>.<pkg>`, so every suite nests under the
|
|
4467
|
+
* metapackage's `<project>/` namespace. That restores `from <project> import
|
|
4468
|
+
* <pkg>` while keeping each suite a separately-installable distribution (and
|
|
4469
|
+
* leaves no top-level `<pkg>` polluting the global namespace). With no project
|
|
4470
|
+
* name the import name is the bare `<pkg>` (top-level fallback). The styxdefs
|
|
4471
|
+
* floor is the only runtime dependency.
|
|
4472
|
+
*
|
|
4473
|
+
* Precondition: `importName` is a valid (possibly dotted) Python package path -
|
|
4474
|
+
* the caller scrubs the project + package names into identifiers accordingly.
|
|
4475
|
+
*/
|
|
4476
|
+
function generateSubPyproject(proj, pkg, importName) {
|
|
4477
|
+
const cb = new CodeBuilder(" ");
|
|
4478
|
+
cb.line("[project]");
|
|
4479
|
+
cb.line(`name = "${tomlStr(pyDistName(proj, pkg))}"`);
|
|
4480
|
+
cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
|
|
4481
|
+
cb.line(`description = "${tomlStr(description$1(pkg.doc, pkg.name))}"`);
|
|
4482
|
+
cb.line(`readme = "README.md"`);
|
|
4483
|
+
cb.line(`license = ${licenseField(proj)}`);
|
|
4484
|
+
cb.line(`authors = ${authorsField(pkg.doc ?? proj.doc)}`);
|
|
4485
|
+
cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
|
|
4486
|
+
cb.line("dependencies = [");
|
|
4487
|
+
cb.line(` "styxdefs${STYXDEFS_COMPAT.python}",`);
|
|
4488
|
+
cb.line("]");
|
|
4489
|
+
cb.blank();
|
|
4490
|
+
cb.line("[tool.setuptools]");
|
|
4491
|
+
cb.line(`packages = ["${importName}"]`);
|
|
4492
|
+
cb.line(`package-dir = { "${importName}" = "." }`);
|
|
4493
|
+
cb.blank();
|
|
4494
|
+
cb.line("[tool.setuptools.package-data]");
|
|
4495
|
+
cb.line(`"${importName}" = ["py.typed"]`);
|
|
4496
|
+
cb.blank();
|
|
4497
|
+
cb.line(BUILD_SYSTEM);
|
|
4498
|
+
return cb.toString() + "\n";
|
|
4499
|
+
}
|
|
4500
|
+
/**
|
|
4501
|
+
* The metapackage's `__init__.py`. A thin re-export of styxkit so the runner
|
|
4502
|
+
* configuration helpers (`use_docker`, `use_local`, `set_global_runner`, ...)
|
|
4503
|
+
* are reachable as `<project>.<name>` - the v1 ergonomic that the v2 split into
|
|
4504
|
+
* per-suite distributions otherwise dropped. The logic lives in styxkit (pulled
|
|
4505
|
+
* in via `styxkit[all]`), so this stays a wildcard re-export and never re-emits
|
|
4506
|
+
* the runner-config code itself.
|
|
4507
|
+
*/
|
|
4508
|
+
function generateRootInitPy() {
|
|
4509
|
+
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";
|
|
4510
|
+
}
|
|
4511
|
+
/**
|
|
4512
|
+
* Root `pyproject.toml`: a metapackage depending on `styxkit[all]` (the runner
|
|
4513
|
+
* stack it re-exports) plus each per-suite distribution. `packages` lists only
|
|
4514
|
+
* the metapackage's own module so setuptools ships the styxkit re-export without
|
|
4515
|
+
* sweeping the sibling suite directories into this distribution. The module is
|
|
4516
|
+
* the `<project>/` namespace package the suites nest into, so installing the
|
|
4517
|
+
* metapackage makes `<project>.use_docker()` reachable alongside the suites'
|
|
4518
|
+
* `from <project> import <pkg>`. `moduleDir` is the on-disk source directory; it
|
|
4519
|
+
* differs from the import `moduleName` only when a suite is named after the
|
|
4520
|
+
* project, in which case a `package-dir` remap keeps the import name intact.
|
|
4521
|
+
*/
|
|
4522
|
+
function generateRootPyproject(proj, distNames, moduleName, moduleDir) {
|
|
4523
|
+
const cb = new CodeBuilder(" ");
|
|
4524
|
+
cb.line("[project]");
|
|
4525
|
+
cb.line(`name = "${tomlStr(proj.name ?? "project")}"`);
|
|
4526
|
+
cb.line(`version = "${tomlStr(proj.version ?? "0.0.0")}"`);
|
|
4527
|
+
cb.line(`description = "${tomlStr(description$1(proj.doc, proj.name))}"`);
|
|
4528
|
+
cb.line(`readme = "README.md"`);
|
|
4529
|
+
cb.line(`license = ${licenseField(proj)}`);
|
|
4530
|
+
cb.line(`authors = ${authorsField(proj.doc)}`);
|
|
4531
|
+
cb.line(`requires-python = "${REQUIRES_PYTHON}"`);
|
|
4532
|
+
cb.line("dependencies = [");
|
|
4533
|
+
for (const dep of PYTHON_RUNNER_DEPS) cb.line(` "${dep}",`);
|
|
4534
|
+
for (const dist of distNames) cb.line(` "${tomlStr(dist)}",`);
|
|
4535
|
+
cb.line("]");
|
|
4536
|
+
cb.blank();
|
|
4537
|
+
cb.line("[tool.setuptools]");
|
|
4538
|
+
cb.line(`packages = ["${moduleName}"]`);
|
|
4539
|
+
if (moduleDir !== moduleName) cb.line(`package-dir = { "${moduleName}" = "${moduleDir}" }`);
|
|
4540
|
+
cb.blank();
|
|
4541
|
+
cb.line("[tool.setuptools.package-data]");
|
|
4542
|
+
cb.line(`"${moduleName}" = ["py.typed"]`);
|
|
4543
|
+
cb.blank();
|
|
4544
|
+
cb.line(BUILD_SYSTEM);
|
|
4545
|
+
return cb.toString() + "\n";
|
|
4546
|
+
}
|
|
4547
|
+
/** Per-suite README crediting the upstream tool authors. */
|
|
4548
|
+
function generateSubReadme(proj, pkg) {
|
|
4549
|
+
const projectTitle = proj.doc?.title ?? proj.name ?? "Styx";
|
|
4550
|
+
const packageTitle = pkg.doc?.title ?? pkg.name ?? "package";
|
|
4551
|
+
const url = pkg.doc?.urls?.[0];
|
|
4552
|
+
const titleMd = url ? `[${packageTitle}](${url})` : packageTitle;
|
|
4553
|
+
const credits = pkg.doc?.authors?.length ? pkg.doc.authors.join(", ") : pkg.doc?.urls?.join(", ") ?? "unknown";
|
|
4554
|
+
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`;
|
|
4555
|
+
}
|
|
4556
|
+
/** Root README listing the bundled per-suite distributions. */
|
|
4557
|
+
function generateRootReadme(proj, distNames) {
|
|
4558
|
+
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`;
|
|
4559
|
+
}
|
|
4560
|
+
/** Local-install manifest: each suite directory first, then the root metapackage. */
|
|
4561
|
+
function generateRequirementsTxt(pkgDirs) {
|
|
4562
|
+
return [...pkgDirs.map((d) => `./${d}`), "./"].join("\n") + "\n";
|
|
4563
|
+
}
|
|
4564
|
+
|
|
4565
|
+
//#endregion
|
|
4566
|
+
//#region src/backend/sig-entries.ts
|
|
4567
|
+
/**
|
|
4568
|
+
* Build per-field signature entries for the kwarg wrapper and params factory.
|
|
4569
|
+
* Skips `@type` (the factory injects it as a constant). Required-no-default
|
|
4570
|
+
* entries are placed before defaulted ones so the resulting signature is
|
|
4571
|
+
* syntactically valid in both Python and TS.
|
|
4572
|
+
*
|
|
4573
|
+
* `registerLocal` is called once per field with the wire key; it must return
|
|
4574
|
+
* a scrubbed, unique host identifier (typically by combining a language-aware
|
|
4575
|
+
* scrub function with `Scope.add()`). The caller's scope should already have
|
|
4576
|
+
* the function's other locals (`params`, `runner`, ...) reserved so this
|
|
4577
|
+
* registration cannot collide with them.
|
|
4352
4578
|
*/
|
|
4353
|
-
function
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
if (
|
|
4365
|
-
if (
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
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
|
-
});
|
|
4579
|
+
function buildSigEntries(rootType, fieldInfo, registerLocal, opts) {
|
|
4580
|
+
const entries = [];
|
|
4581
|
+
for (const [fieldName, fieldType] of Object.entries(rootType.fields)) {
|
|
4582
|
+
if (fieldType.kind === "literal") continue;
|
|
4583
|
+
const fi = fieldInfo.get(fieldName);
|
|
4584
|
+
const isOptional = fieldType.kind === "optional";
|
|
4585
|
+
const inner = isOptional ? fieldType.inner : fieldType;
|
|
4586
|
+
let sigType = opts.renderType(inner);
|
|
4587
|
+
if (isOptional) sigType += opts.nullableSuffix;
|
|
4588
|
+
let sigDefault;
|
|
4589
|
+
const hasExplicitDefault = fi?.defaultValue !== void 0;
|
|
4590
|
+
if (hasExplicitDefault) sigDefault = opts.renderDefault(fi.defaultValue);
|
|
4591
|
+
else if (isOptional) sigDefault = opts.nullableDefault;
|
|
4592
|
+
entries.push({
|
|
4593
|
+
name: registerLocal(fieldName),
|
|
4594
|
+
wireKey: fieldName,
|
|
4595
|
+
sigType,
|
|
4596
|
+
sigDefault,
|
|
4597
|
+
isOptional,
|
|
4598
|
+
hasExplicitDefault,
|
|
4599
|
+
doc: fi?.doc
|
|
4600
|
+
});
|
|
4601
|
+
}
|
|
4602
|
+
const required = entries.filter((e) => e.sigDefault === void 0);
|
|
4603
|
+
const defaulted = entries.filter((e) => e.sigDefault !== void 0);
|
|
4604
|
+
return [...required, ...defaulted];
|
|
4397
4605
|
}
|
|
4398
4606
|
|
|
4399
4607
|
//#endregion
|
|
@@ -5157,7 +5365,7 @@ function computePublicNames$1(appId) {
|
|
|
5157
5365
|
* (the `reg` registrations and the `sigScope` child), so passing the same scope
|
|
5158
5366
|
* the emitter continues with keeps later local registrations consistent.
|
|
5159
5367
|
*/
|
|
5160
|
-
function buildEmitModel
|
|
5368
|
+
function buildEmitModel(ctx, scope = new Scope(PY_RESERVED)) {
|
|
5161
5369
|
const appId = ctx.app?.id;
|
|
5162
5370
|
const pkg = ctx.package?.name;
|
|
5163
5371
|
const publicNames = computePublicNames$1(appId);
|
|
@@ -5200,7 +5408,7 @@ function buildEmitModel$1(ctx, scope = new Scope(PY_RESERVED)) {
|
|
|
5200
5408
|
function generatePython(ctx, packageScope) {
|
|
5201
5409
|
const cb = new CodeBuilder(" ");
|
|
5202
5410
|
const scope = packageScope ?? new Scope(PY_RESERVED);
|
|
5203
|
-
const { names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel
|
|
5411
|
+
const { names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel(ctx, scope);
|
|
5204
5412
|
cb.comment("This file was auto generated by Styx.", "# ");
|
|
5205
5413
|
cb.comment("Do not edit this file directly.", "# ");
|
|
5206
5414
|
cb.blank();
|
|
@@ -5347,21 +5555,33 @@ var PythonBackend = class {
|
|
|
5347
5555
|
const files = /* @__PURE__ */ new Map();
|
|
5348
5556
|
const distNames = [];
|
|
5349
5557
|
const pkgDirs = [];
|
|
5558
|
+
const warnings = [];
|
|
5559
|
+
const nsName = proj.name && proj.name.trim() ? pyScrubIdent(proj.name, PY_RESERVED) : void 0;
|
|
5350
5560
|
for (const p of packages) {
|
|
5351
5561
|
const pkg = p.meta ?? {};
|
|
5352
5562
|
const dir = pkg.name ?? "package";
|
|
5353
5563
|
pkgDirs.push(dir);
|
|
5354
5564
|
distNames.push(pyDistName(proj, pkg));
|
|
5355
|
-
|
|
5565
|
+
const importPkg = nsName ? `${nsName}.${dir}` : dir;
|
|
5566
|
+
files.set(`${dir}/pyproject.toml`, generateSubPyproject(proj, pkg, importPkg));
|
|
5356
5567
|
files.set(`${dir}/README.md`, generateSubReadme(proj, pkg));
|
|
5357
5568
|
}
|
|
5358
|
-
|
|
5569
|
+
const metaImport = nsName ?? "project";
|
|
5570
|
+
let metaDir = metaImport;
|
|
5571
|
+
if (pkgDirs.includes(metaDir)) {
|
|
5572
|
+
const collided = metaDir;
|
|
5573
|
+
while (pkgDirs.includes(metaDir)) metaDir += "_";
|
|
5574
|
+
warnings.push({ message: `metapackage directory "${collided}" collides with a suite directory; emitting its sources in "${metaDir}" instead (import name stays "${metaImport}")` });
|
|
5575
|
+
}
|
|
5576
|
+
files.set(`${metaDir}/__init__.py`, generateRootInitPy());
|
|
5577
|
+
files.set(`${metaDir}/py.typed`, "");
|
|
5578
|
+
files.set("pyproject.toml", generateRootPyproject(proj, distNames, metaImport, metaDir));
|
|
5359
5579
|
files.set("README.md", generateRootReadme(proj, distNames));
|
|
5360
5580
|
files.set("requirements.txt", generateRequirementsTxt(pkgDirs));
|
|
5361
5581
|
return {
|
|
5362
5582
|
files,
|
|
5363
5583
|
errors: [],
|
|
5364
|
-
warnings
|
|
5584
|
+
warnings
|
|
5365
5585
|
};
|
|
5366
5586
|
}
|
|
5367
5587
|
};
|
|
@@ -5507,7 +5727,7 @@ const pyDialect = {
|
|
|
5507
5727
|
* @param opts - Import and package-root options.
|
|
5508
5728
|
*/
|
|
5509
5729
|
function renderPythonCall(ctx, config, opts = {}) {
|
|
5510
|
-
const model = buildEmitModel
|
|
5730
|
+
const model = buildEmitModel(ctx);
|
|
5511
5731
|
const pkg = ctx.package?.name;
|
|
5512
5732
|
const callee = pkg ? `${pkg}.${model.names.wrapper}` : model.names.wrapper;
|
|
5513
5733
|
const call = model.rootIsStruct && model.rootType.kind === "struct" ? renderKwargCall(callee, model.sigEntries, model.rootType, config) : `${callee}(${renderValue(config, model.rootType, "", pyDialect)})`;
|
|
@@ -5530,6 +5750,495 @@ function renderKwargCall(callee, sigEntries, rootType, config) {
|
|
|
5530
5750
|
return `${callee}(\n${lines.join("\n")}\n)`;
|
|
5531
5751
|
}
|
|
5532
5752
|
|
|
5753
|
+
//#endregion
|
|
5754
|
+
//#region src/backend/typed-spec.ts
|
|
5755
|
+
/** Project a tool's solved tree into the flat typed spec for delegation backends. */
|
|
5756
|
+
function buildTypedSpec(ctx) {
|
|
5757
|
+
const model = buildEmitModel(ctx);
|
|
5758
|
+
const delegation = {
|
|
5759
|
+
moduleName: appModuleName$1(ctx.app),
|
|
5760
|
+
wrapperFn: model.names.wrapper,
|
|
5761
|
+
outputsClass: model.names.outputs
|
|
5762
|
+
};
|
|
5763
|
+
const outputs = collectOutputFields(ctx, pyId);
|
|
5764
|
+
const streams = streamFields(ctx, pyId);
|
|
5765
|
+
if (!model.rootIsStruct || model.rootType.kind !== "struct") return {
|
|
5766
|
+
rootIsStruct: false,
|
|
5767
|
+
params: [],
|
|
5768
|
+
outputs,
|
|
5769
|
+
streams,
|
|
5770
|
+
delegation
|
|
5771
|
+
};
|
|
5772
|
+
const rootType = model.rootType;
|
|
5773
|
+
const fieldByName = new Map(structFields(ctx, rootType, ctx.expr).map((f) => [f.name, f]));
|
|
5774
|
+
const fieldInfo = collectFieldInfo(ctx, rootType);
|
|
5775
|
+
return {
|
|
5776
|
+
rootIsStruct: true,
|
|
5777
|
+
params: model.sigEntries.map((e) => {
|
|
5778
|
+
const fe = fieldByName.get(e.wireKey);
|
|
5779
|
+
const fi = fieldInfo.get(e.wireKey);
|
|
5780
|
+
return describeParam(e, fe?.type, fe?.node, fi?.defaultValue, fi?.doc);
|
|
5781
|
+
}),
|
|
5782
|
+
outputs,
|
|
5783
|
+
streams,
|
|
5784
|
+
delegation
|
|
5785
|
+
};
|
|
5786
|
+
}
|
|
5787
|
+
function describeParam(e, type, node, defaultValue, fallbackDoc) {
|
|
5788
|
+
const optional = e.isOptional;
|
|
5789
|
+
const hasDefault = e.hasExplicitDefault;
|
|
5790
|
+
const base = {
|
|
5791
|
+
hostName: e.name,
|
|
5792
|
+
wireKey: e.wireKey,
|
|
5793
|
+
optional,
|
|
5794
|
+
hasDefault,
|
|
5795
|
+
mandatory: !optional && !hasDefault,
|
|
5796
|
+
default: defaultValue,
|
|
5797
|
+
doc: e.doc ?? fallbackDoc
|
|
5798
|
+
};
|
|
5799
|
+
const inner = type && type.kind === "optional" ? type.inner : type;
|
|
5800
|
+
const choices = inner ? literalChoices(inner, node) : void 0;
|
|
5801
|
+
if (choices) return {
|
|
5802
|
+
...base,
|
|
5803
|
+
kind: "enum",
|
|
5804
|
+
choices
|
|
5805
|
+
};
|
|
5806
|
+
switch (inner?.kind) {
|
|
5807
|
+
case "scalar":
|
|
5808
|
+
if (inner.scalar === "path") return {
|
|
5809
|
+
...base,
|
|
5810
|
+
kind: "path",
|
|
5811
|
+
scalarKind: "path",
|
|
5812
|
+
mediaTypes: pathMediaTypes(node)
|
|
5813
|
+
};
|
|
5814
|
+
if (inner.scalar === "int" || inner.scalar === "float") return {
|
|
5815
|
+
...base,
|
|
5816
|
+
kind: inner.scalar,
|
|
5817
|
+
scalarKind: inner.scalar,
|
|
5818
|
+
range: numericRange(node)
|
|
5819
|
+
};
|
|
5820
|
+
return {
|
|
5821
|
+
...base,
|
|
5822
|
+
kind: "str",
|
|
5823
|
+
scalarKind: "str"
|
|
5824
|
+
};
|
|
5825
|
+
case "bool": return {
|
|
5826
|
+
...base,
|
|
5827
|
+
kind: "bool"
|
|
5828
|
+
};
|
|
5829
|
+
case "count": return {
|
|
5830
|
+
...base,
|
|
5831
|
+
kind: "count"
|
|
5832
|
+
};
|
|
5833
|
+
case "list": return {
|
|
5834
|
+
...base,
|
|
5835
|
+
kind: "list",
|
|
5836
|
+
listBounds: listBounds(node),
|
|
5837
|
+
itemType: describeItem(inner.item, node)
|
|
5838
|
+
};
|
|
5839
|
+
case "struct": return {
|
|
5840
|
+
...base,
|
|
5841
|
+
kind: "struct"
|
|
5842
|
+
};
|
|
5843
|
+
case "union": return {
|
|
5844
|
+
...base,
|
|
5845
|
+
kind: "union"
|
|
5846
|
+
};
|
|
5847
|
+
default: return {
|
|
5848
|
+
...base,
|
|
5849
|
+
kind: "str"
|
|
5850
|
+
};
|
|
5851
|
+
}
|
|
5852
|
+
}
|
|
5853
|
+
function describeItem(item, node) {
|
|
5854
|
+
const inner = item.kind === "optional" ? item.inner : item;
|
|
5855
|
+
const choices = literalChoices(inner, node);
|
|
5856
|
+
if (choices) return {
|
|
5857
|
+
kind: "enum",
|
|
5858
|
+
choices
|
|
5859
|
+
};
|
|
5860
|
+
switch (inner.kind) {
|
|
5861
|
+
case "scalar":
|
|
5862
|
+
if (inner.scalar === "path") return {
|
|
5863
|
+
kind: "path",
|
|
5864
|
+
scalarKind: "path",
|
|
5865
|
+
mediaTypes: pathMediaTypes(node)
|
|
5866
|
+
};
|
|
5867
|
+
return {
|
|
5868
|
+
kind: inner.scalar,
|
|
5869
|
+
scalarKind: inner.scalar
|
|
5870
|
+
};
|
|
5871
|
+
case "bool": return { kind: "bool" };
|
|
5872
|
+
case "count": return { kind: "count" };
|
|
5873
|
+
case "struct": return { kind: "struct" };
|
|
5874
|
+
case "union": return { kind: "union" };
|
|
5875
|
+
default: return { kind: "str" };
|
|
5876
|
+
}
|
|
5877
|
+
}
|
|
5878
|
+
/** Allowed values when `inner` is a literal or a union of literals (else undefined). */
|
|
5879
|
+
function literalChoices(inner, node) {
|
|
5880
|
+
if (inner.kind === "literal") return [inner.value];
|
|
5881
|
+
if (inner.kind === "union" && inner.variants.length > 0 && inner.variants.every((v) => v.type.kind === "literal")) return inner.variants.map((v) => v.type.value);
|
|
5882
|
+
const altNode = node ? findNode(node, (n) => n.kind === "alternative") : void 0;
|
|
5883
|
+
if (altNode && altNode.kind === "alternative") {
|
|
5884
|
+
const alts = altNode.attrs.alts;
|
|
5885
|
+
if (alts.length > 0 && alts.every((a) => a.kind === "literal")) return alts.map((a) => a.attrs.str);
|
|
5886
|
+
}
|
|
5887
|
+
}
|
|
5888
|
+
function numericRange(node) {
|
|
5889
|
+
const r = findRangeNode(node);
|
|
5890
|
+
if (!r) return void 0;
|
|
5891
|
+
const { minValue, maxValue } = r.attrs;
|
|
5892
|
+
if (minValue === void 0 && maxValue === void 0) return void 0;
|
|
5893
|
+
return {
|
|
5894
|
+
min: minValue,
|
|
5895
|
+
max: maxValue
|
|
5896
|
+
};
|
|
5897
|
+
}
|
|
5898
|
+
function listBounds(node) {
|
|
5899
|
+
const r = findRepeatNode(node);
|
|
5900
|
+
if (!r) return void 0;
|
|
5901
|
+
const { countMin, countMax } = r.attrs;
|
|
5902
|
+
if (countMin === void 0 && countMax === void 0) return void 0;
|
|
5903
|
+
return {
|
|
5904
|
+
min: countMin,
|
|
5905
|
+
max: countMax
|
|
5906
|
+
};
|
|
5907
|
+
}
|
|
5908
|
+
function pathMediaTypes(node) {
|
|
5909
|
+
const p = node ? findNode(node, (n) => n.kind === "path") : void 0;
|
|
5910
|
+
if (p && p.kind === "path") {
|
|
5911
|
+
const mt = p.attrs.mediaTypes;
|
|
5912
|
+
if (mt && mt.length > 0) return mt;
|
|
5913
|
+
}
|
|
5914
|
+
}
|
|
5915
|
+
|
|
5916
|
+
//#endregion
|
|
5917
|
+
//#region src/backend/nipype/nipype.ts
|
|
5918
|
+
/** Derive the per-tool nipype module/class names. */
|
|
5919
|
+
function nipypeNames(ctx) {
|
|
5920
|
+
const mod = appModuleName$1(ctx.app);
|
|
5921
|
+
const rawId = ctx.app?.id ?? "tool";
|
|
5922
|
+
const cls = pascalCase(/^[0-9]/.test(rawId) ? "v_" + rawId : rawId);
|
|
5923
|
+
return {
|
|
5924
|
+
styxStem: `_${mod}`,
|
|
5925
|
+
ifaceStem: mod,
|
|
5926
|
+
cls,
|
|
5927
|
+
inputSpec: `${cls}InputSpec`,
|
|
5928
|
+
outputSpec: `${cls}OutputSpec`
|
|
5929
|
+
};
|
|
5930
|
+
}
|
|
5931
|
+
/** Generate the nipype interface module source for one tool. */
|
|
5932
|
+
function generateNipype(ctx) {
|
|
5933
|
+
return emitNipypeInterface(ctx, buildTypedSpec(ctx), nipypeNames(ctx));
|
|
5934
|
+
}
|
|
5935
|
+
/**
|
|
5936
|
+
* Emits nipype `Interface` definitions whose typed InputSpec/OutputSpec carry
|
|
5937
|
+
* rich constraints (numeric ranges, list bounds, enum choices, file types) and
|
|
5938
|
+
* which delegate execution to the styx Python wrapper (Option B): no command-line
|
|
5939
|
+
* arg-building or output-path resolution is re-implemented here.
|
|
5940
|
+
*
|
|
5941
|
+
* Per tool, two co-located files are emitted so the output is a self-contained,
|
|
5942
|
+
* importable Python package: `_<tool>.py` (the styx Python module) and
|
|
5943
|
+
* `<tool>.py` (the interface, importing the wrapper via a relative import).
|
|
5944
|
+
*/
|
|
5945
|
+
var NipypeBackend = class {
|
|
5946
|
+
name = "nipype";
|
|
5947
|
+
target = "nipype";
|
|
5948
|
+
emitApp(ctx, _scope) {
|
|
5949
|
+
const names = nipypeNames(ctx);
|
|
5950
|
+
const spec = buildTypedSpec(ctx);
|
|
5951
|
+
const warnings = [];
|
|
5952
|
+
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.` });
|
|
5953
|
+
const styxCode = generatePython(ctx);
|
|
5954
|
+
const ifaceCode = emitNipypeInterface(ctx, spec, names);
|
|
5955
|
+
return {
|
|
5956
|
+
meta: ctx.app,
|
|
5957
|
+
files: new Map([[`${names.styxStem}.py`, styxCode], [`${names.ifaceStem}.py`, ifaceCode]]),
|
|
5958
|
+
errors: [],
|
|
5959
|
+
warnings
|
|
5960
|
+
};
|
|
5961
|
+
}
|
|
5962
|
+
};
|
|
5963
|
+
|
|
5964
|
+
//#endregion
|
|
5965
|
+
//#region src/backend/pydra/emit.ts
|
|
5966
|
+
/** A scalar enum's Python type: int when every choice is numeric, else str. */
|
|
5967
|
+
function enumScalar(choices) {
|
|
5968
|
+
return (choices ?? []).every((c) => typeof c === "number") ? "int" : "str";
|
|
5969
|
+
}
|
|
5970
|
+
/** True when a parameter is a file path (a path scalar or a list of paths). */
|
|
5971
|
+
function isPathParam(p) {
|
|
5972
|
+
return p.kind === "path" || p.kind === "list" && p.itemType?.kind === "path";
|
|
5973
|
+
}
|
|
5974
|
+
function itemTypeStr(item, imp) {
|
|
5975
|
+
if (!item) {
|
|
5976
|
+
imp.typing = true;
|
|
5977
|
+
return "ty.Any";
|
|
5978
|
+
}
|
|
5979
|
+
switch (item.kind) {
|
|
5980
|
+
case "path":
|
|
5981
|
+
imp.file = true;
|
|
5982
|
+
return "File";
|
|
5983
|
+
case "int":
|
|
5984
|
+
case "count": return "int";
|
|
5985
|
+
case "float": return "float";
|
|
5986
|
+
case "str": return "str";
|
|
5987
|
+
case "bool": return "bool";
|
|
5988
|
+
case "enum": return enumScalar(item.choices);
|
|
5989
|
+
default:
|
|
5990
|
+
imp.typing = true;
|
|
5991
|
+
return "ty.Any";
|
|
5992
|
+
}
|
|
5993
|
+
}
|
|
5994
|
+
/** Base (non-optional) Python type expression for a parameter. */
|
|
5995
|
+
function baseType(p, imp) {
|
|
5996
|
+
switch (p.kind) {
|
|
5997
|
+
case "path":
|
|
5998
|
+
imp.file = true;
|
|
5999
|
+
return "File";
|
|
6000
|
+
case "int":
|
|
6001
|
+
case "count": return "int";
|
|
6002
|
+
case "float": return "float";
|
|
6003
|
+
case "str": return "str";
|
|
6004
|
+
case "bool": return "bool";
|
|
6005
|
+
case "enum": return enumScalar(p.choices);
|
|
6006
|
+
case "list": return `list[${itemTypeStr(p.itemType, imp)}]`;
|
|
6007
|
+
case "struct":
|
|
6008
|
+
case "union":
|
|
6009
|
+
imp.typing = true;
|
|
6010
|
+
return "ty.Any";
|
|
6011
|
+
}
|
|
6012
|
+
}
|
|
6013
|
+
/** Full input type, wrapping in `ty.Optional[...]` for an omittable-no-default field. */
|
|
6014
|
+
function inputType(p, imp) {
|
|
6015
|
+
const base = baseType(p, imp);
|
|
6016
|
+
if (p.optional && !p.hasDefault) {
|
|
6017
|
+
imp.typing = true;
|
|
6018
|
+
return `ty.Optional[${base}]`;
|
|
6019
|
+
}
|
|
6020
|
+
return base;
|
|
6021
|
+
}
|
|
6022
|
+
/** An attrs validator enforcing numeric range / list-length bounds, or undefined. */
|
|
6023
|
+
function validatorExpr(p, imp) {
|
|
6024
|
+
const checks = [];
|
|
6025
|
+
if (p.range) {
|
|
6026
|
+
if (p.range.min !== void 0) checks.push(`attrs.validators.ge(${renderPyLiteral(p.range.min)})`);
|
|
6027
|
+
if (p.range.max !== void 0) checks.push(`attrs.validators.le(${renderPyLiteral(p.range.max)})`);
|
|
6028
|
+
}
|
|
6029
|
+
if (p.listBounds) {
|
|
6030
|
+
if (p.listBounds.min !== void 0) checks.push(`attrs.validators.min_len(${p.listBounds.min})`);
|
|
6031
|
+
if (p.listBounds.max !== void 0) checks.push(`attrs.validators.max_len(${p.listBounds.max})`);
|
|
6032
|
+
}
|
|
6033
|
+
if (checks.length === 0) return void 0;
|
|
6034
|
+
imp.attrs = true;
|
|
6035
|
+
return `_styx_optional(${checks.length === 1 ? checks[0] : `attrs.validators.and_(${checks.join(", ")})`})`;
|
|
6036
|
+
}
|
|
6037
|
+
/** Help text: doc + degrade / media-type notes. */
|
|
6038
|
+
function helpText(p) {
|
|
6039
|
+
const parts = [];
|
|
6040
|
+
if (p.doc) parts.push(p.doc);
|
|
6041
|
+
if (p.kind === "struct" || p.kind === "union") parts.push("(nested configuration; pass a dict)");
|
|
6042
|
+
if (p.mediaTypes && p.mediaTypes.length > 0) parts.push(`(media types: ${p.mediaTypes.join(", ")})`);
|
|
6043
|
+
return parts.length > 0 ? parts.join(" ") : void 0;
|
|
6044
|
+
}
|
|
6045
|
+
/** Render a `python.arg(...)` field for one parameter. */
|
|
6046
|
+
function renderInputArg(p, imp) {
|
|
6047
|
+
const args = [`type=${inputType(p, imp)}`];
|
|
6048
|
+
if (p.mandatory) {} else if (p.optional && !p.hasDefault) args.push("default=None");
|
|
6049
|
+
else args.push(`default=${renderPyLiteral(p.default)}`);
|
|
6050
|
+
if (p.kind === "enum") args.push(`allowed_values=[${(p.choices ?? []).map((c) => renderPyLiteral(c)).join(", ")}]`);
|
|
6051
|
+
const v = validatorExpr(p, imp);
|
|
6052
|
+
if (v) args.push(`validator=${v}`);
|
|
6053
|
+
const h = helpText(p);
|
|
6054
|
+
if (h) args.push(`help=${pyStr(h)}`);
|
|
6055
|
+
return `python.arg(${args.join(", ")})`;
|
|
6056
|
+
}
|
|
6057
|
+
/** Python type for an output field. `isRoot` is the synthetic output directory. */
|
|
6058
|
+
function outputType(f, isRoot, imp) {
|
|
6059
|
+
if (f.shape.kind === "list") {
|
|
6060
|
+
imp.file = true;
|
|
6061
|
+
return "list[File]";
|
|
6062
|
+
}
|
|
6063
|
+
if (isRoot) {
|
|
6064
|
+
imp.directory = true;
|
|
6065
|
+
return "Directory";
|
|
6066
|
+
}
|
|
6067
|
+
imp.file = true;
|
|
6068
|
+
if (f.shape.optional) {
|
|
6069
|
+
imp.typing = true;
|
|
6070
|
+
return "ty.Optional[File]";
|
|
6071
|
+
}
|
|
6072
|
+
return "File";
|
|
6073
|
+
}
|
|
6074
|
+
function renderOutputArg(f, isRoot, imp) {
|
|
6075
|
+
const args = [`type=${outputType(f, isRoot, imp)}`];
|
|
6076
|
+
if (f.doc) args.push(`help=${pyStr(f.doc)}`);
|
|
6077
|
+
return `python.out(${args.join(", ")})`;
|
|
6078
|
+
}
|
|
6079
|
+
/**
|
|
6080
|
+
* Emit the pydra task module for one tool: a `@python.define` task whose typed
|
|
6081
|
+
* inputs/outputs carry rich constraints and whose body delegates execution to the
|
|
6082
|
+
* co-emitted styx Python wrapper (Option B). Targets the post-rewrite
|
|
6083
|
+
* `pydra.compose` API (pydra >= 1.0a, Python >= 3.11).
|
|
6084
|
+
*/
|
|
6085
|
+
function emitPydraTask(ctx, spec, names) {
|
|
6086
|
+
const imp = {
|
|
6087
|
+
typing: false,
|
|
6088
|
+
attrs: false,
|
|
6089
|
+
file: false,
|
|
6090
|
+
directory: false
|
|
6091
|
+
};
|
|
6092
|
+
const inputEntries = spec.params.map((p) => ({
|
|
6093
|
+
key: p.hostName,
|
|
6094
|
+
expr: renderInputArg(p, imp)
|
|
6095
|
+
}));
|
|
6096
|
+
const outputEntries = spec.outputs.map((f, i) => ({
|
|
6097
|
+
key: f.id,
|
|
6098
|
+
expr: renderOutputArg(f, i === 0, imp)
|
|
6099
|
+
}));
|
|
6100
|
+
for (const s of spec.streams) outputEntries.push({
|
|
6101
|
+
key: s.id,
|
|
6102
|
+
expr: `python.out(type=list[str]${s.doc ? `, help=${pyStr(s.doc)}` : ""})`
|
|
6103
|
+
});
|
|
6104
|
+
const needsPath = spec.params.some(isPathParam);
|
|
6105
|
+
const cb = new CodeBuilder(" ");
|
|
6106
|
+
cb.comment("This file was auto generated by Styx.", "# ");
|
|
6107
|
+
cb.comment("Do not edit this file directly.", "# ");
|
|
6108
|
+
cb.comment("Targets the pydra.compose API (pydra >= 1.0a, Python >= 3.11).", "# ");
|
|
6109
|
+
cb.blank();
|
|
6110
|
+
if (needsPath) cb.line("import os");
|
|
6111
|
+
if (imp.typing) cb.line("import typing as ty");
|
|
6112
|
+
if (imp.attrs) cb.line("import attrs.validators");
|
|
6113
|
+
cb.line("from pydra.compose import python");
|
|
6114
|
+
const ff = [];
|
|
6115
|
+
if (imp.directory) ff.push("Directory");
|
|
6116
|
+
if (imp.file) ff.push("File");
|
|
6117
|
+
if (ff.length > 0) cb.line(`from fileformats.generic import ${ff.join(", ")}`);
|
|
6118
|
+
cb.blank();
|
|
6119
|
+
cb.line(`from .${names.styxStem} import ${spec.delegation.wrapperFn}`);
|
|
6120
|
+
cb.blank();
|
|
6121
|
+
cb.blank();
|
|
6122
|
+
if (imp.attrs) {
|
|
6123
|
+
cb.line("def _styx_optional(validator):");
|
|
6124
|
+
cb.indent(() => {
|
|
6125
|
+
cb.line("\"\"\"Apply an attrs validator only to a set (non-NOTHING), non-None value.\"\"\"");
|
|
6126
|
+
cb.line("def _check(instance, attribute, value):");
|
|
6127
|
+
cb.indent(() => {
|
|
6128
|
+
cb.line("if value is attrs.NOTHING or value is None:");
|
|
6129
|
+
cb.indent(() => cb.line("return"));
|
|
6130
|
+
cb.line("validator(instance, attribute, value)");
|
|
6131
|
+
});
|
|
6132
|
+
cb.line("return _check");
|
|
6133
|
+
});
|
|
6134
|
+
cb.blank();
|
|
6135
|
+
cb.blank();
|
|
6136
|
+
}
|
|
6137
|
+
if (needsPath) {
|
|
6138
|
+
cb.line("def _styx_path(value):");
|
|
6139
|
+
cb.indent(() => {
|
|
6140
|
+
cb.line("\"\"\"Convert fileformats / Path inputs into a styxdefs-accepted path.\"\"\"");
|
|
6141
|
+
cb.line("if value is None:");
|
|
6142
|
+
cb.indent(() => cb.line("return None"));
|
|
6143
|
+
cb.line("if isinstance(value, (list, tuple)):");
|
|
6144
|
+
cb.indent(() => cb.line("return [os.fspath(v) for v in value]"));
|
|
6145
|
+
cb.line("return os.fspath(value)");
|
|
6146
|
+
});
|
|
6147
|
+
cb.blank();
|
|
6148
|
+
cb.blank();
|
|
6149
|
+
}
|
|
6150
|
+
cb.line("@python.define(");
|
|
6151
|
+
cb.indent(() => {
|
|
6152
|
+
if (inputEntries.length === 0) cb.line("inputs={},");
|
|
6153
|
+
else {
|
|
6154
|
+
cb.line("inputs={");
|
|
6155
|
+
cb.indent(() => {
|
|
6156
|
+
for (const e of inputEntries) cb.line(`${pyStr(e.key)}: ${e.expr},`);
|
|
6157
|
+
});
|
|
6158
|
+
cb.line("},");
|
|
6159
|
+
}
|
|
6160
|
+
cb.line("outputs={");
|
|
6161
|
+
cb.indent(() => {
|
|
6162
|
+
for (const e of outputEntries) cb.line(`${pyStr(e.key)}: ${e.expr},`);
|
|
6163
|
+
});
|
|
6164
|
+
cb.line("},");
|
|
6165
|
+
});
|
|
6166
|
+
cb.line(")");
|
|
6167
|
+
const paramList = spec.params.map((p) => p.hostName);
|
|
6168
|
+
cb.line(`def ${names.cls}(${paramList.join(", ")}):`);
|
|
6169
|
+
cb.indent(() => {
|
|
6170
|
+
emitDocstring(cb, [ctx.app?.doc?.title, ctx.app?.doc?.description].filter(Boolean).join("\n\n") || void 0);
|
|
6171
|
+
if (!spec.rootIsStruct) {
|
|
6172
|
+
cb.line("raise NotImplementedError(");
|
|
6173
|
+
cb.indent(() => cb.line(pyStr("styx pydra backend: tools with a non-struct root are not supported.")));
|
|
6174
|
+
cb.line(")");
|
|
6175
|
+
return;
|
|
6176
|
+
}
|
|
6177
|
+
if (spec.params.length === 0) cb.line(`result = ${spec.delegation.wrapperFn}()`);
|
|
6178
|
+
else {
|
|
6179
|
+
cb.line(`result = ${spec.delegation.wrapperFn}(`);
|
|
6180
|
+
cb.indent(() => {
|
|
6181
|
+
for (const p of spec.params) {
|
|
6182
|
+
const expr = isPathParam(p) ? `_styx_path(${p.hostName})` : p.hostName;
|
|
6183
|
+
cb.line(`${p.hostName}=${expr},`);
|
|
6184
|
+
}
|
|
6185
|
+
});
|
|
6186
|
+
cb.line(")");
|
|
6187
|
+
}
|
|
6188
|
+
const returns = [...spec.outputs.map((f) => `result.${f.id}`), ...spec.streams.map((s) => `result.${s.id}`)];
|
|
6189
|
+
if (returns.length === 1) cb.line(`return ${returns[0]}`);
|
|
6190
|
+
else cb.line(`return ${returns.join(", ")}`);
|
|
6191
|
+
});
|
|
6192
|
+
return cb.toString();
|
|
6193
|
+
}
|
|
6194
|
+
|
|
6195
|
+
//#endregion
|
|
6196
|
+
//#region src/backend/pydra/pydra.ts
|
|
6197
|
+
/** Derive the per-tool pydra module/class names. */
|
|
6198
|
+
function pydraNames(ctx) {
|
|
6199
|
+
const mod = appModuleName$1(ctx.app);
|
|
6200
|
+
const rawId = ctx.app?.id ?? "tool";
|
|
6201
|
+
const safeId = /^[0-9]/.test(rawId) ? "v_" + rawId : rawId;
|
|
6202
|
+
return {
|
|
6203
|
+
styxStem: `_${mod}`,
|
|
6204
|
+
ifaceStem: mod,
|
|
6205
|
+
cls: pascalCase(safeId)
|
|
6206
|
+
};
|
|
6207
|
+
}
|
|
6208
|
+
/** Generate the pydra task module source for one tool. */
|
|
6209
|
+
function generatePydra(ctx) {
|
|
6210
|
+
return emitPydraTask(ctx, buildTypedSpec(ctx), pydraNames(ctx));
|
|
6211
|
+
}
|
|
6212
|
+
/**
|
|
6213
|
+
* Emits pydra tasks (`@python.define`, the post-rewrite pydra.compose API) whose
|
|
6214
|
+
* typed inputs/outputs carry rich constraints (numeric ranges and list bounds via
|
|
6215
|
+
* attrs validators, enum choices, file types, defaults) and which delegate
|
|
6216
|
+
* execution to the styx Python wrapper (Option B): no command-line arg-building
|
|
6217
|
+
* or output-path resolution is re-implemented here.
|
|
6218
|
+
*
|
|
6219
|
+
* Per tool, two co-located files are emitted so the output is a self-contained,
|
|
6220
|
+
* importable Python package: `_<tool>.py` (the styx Python module) and
|
|
6221
|
+
* `<tool>.py` (the task, importing the wrapper via a relative import).
|
|
6222
|
+
*/
|
|
6223
|
+
var PydraBackend = class {
|
|
6224
|
+
name = "pydra";
|
|
6225
|
+
target = "pydra";
|
|
6226
|
+
emitApp(ctx, _scope) {
|
|
6227
|
+
const names = pydraNames(ctx);
|
|
6228
|
+
const spec = buildTypedSpec(ctx);
|
|
6229
|
+
const warnings = [];
|
|
6230
|
+
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.` });
|
|
6231
|
+
const styxCode = generatePython(ctx);
|
|
6232
|
+
const taskCode = emitPydraTask(ctx, spec, names);
|
|
6233
|
+
return {
|
|
6234
|
+
meta: ctx.app,
|
|
6235
|
+
files: new Map([[`${names.styxStem}.py`, styxCode], [`${names.ifaceStem}.py`, taskCode]]),
|
|
6236
|
+
errors: [],
|
|
6237
|
+
warnings
|
|
6238
|
+
};
|
|
6239
|
+
}
|
|
6240
|
+
};
|
|
6241
|
+
|
|
5533
6242
|
//#endregion
|
|
5534
6243
|
//#region src/backend/schema/jsonschema.ts
|
|
5535
6244
|
var SchemaBuilder = class {
|
|
@@ -5575,7 +6284,7 @@ var SchemaBuilder = class {
|
|
|
5575
6284
|
case "literal": return { const: type.value };
|
|
5576
6285
|
case "optional": return this.fromType(type.inner, node?.kind === "optional" ? node.attrs.node : void 0);
|
|
5577
6286
|
case "list": {
|
|
5578
|
-
const repeat = node
|
|
6287
|
+
const repeat = findRepeatNode(node);
|
|
5579
6288
|
const schema = {
|
|
5580
6289
|
type: "array",
|
|
5581
6290
|
items: this.fromType(type.item, repeat?.attrs.node)
|
|
@@ -5585,7 +6294,7 @@ var SchemaBuilder = class {
|
|
|
5585
6294
|
return schema;
|
|
5586
6295
|
}
|
|
5587
6296
|
case "struct": return this.structSchema(type, node);
|
|
5588
|
-
case "union": return this.unionSchema(type);
|
|
6297
|
+
case "union": return this.unionSchema(type, node);
|
|
5589
6298
|
}
|
|
5590
6299
|
}
|
|
5591
6300
|
findTerminal(node) {
|
|
@@ -5646,9 +6355,20 @@ var SchemaBuilder = class {
|
|
|
5646
6355
|
if (required.length > 0) schema.required = required;
|
|
5647
6356
|
return schema;
|
|
5648
6357
|
}
|
|
5649
|
-
unionSchema(type) {
|
|
6358
|
+
unionSchema(type, node) {
|
|
5650
6359
|
if (type.variants.every((v) => v.type.kind === "literal")) return { enum: type.variants.map((v) => v.type.kind === "literal" ? v.type.value : "") };
|
|
5651
|
-
|
|
6360
|
+
const altNode = findAlternativeNode(node);
|
|
6361
|
+
return { oneOf: type.variants.map((v, i) => {
|
|
6362
|
+
const schema = this.fromType(v.type, altNode?.attrs.alts[i]);
|
|
6363
|
+
if (v.type.kind === "struct" && "@type" in v.type.fields && schema.properties && !("@type" in schema.properties)) {
|
|
6364
|
+
schema.properties = {
|
|
6365
|
+
"@type": this.fromType(v.type.fields["@type"]),
|
|
6366
|
+
...schema.properties
|
|
6367
|
+
};
|
|
6368
|
+
schema.required = ["@type", ...schema.required ?? []];
|
|
6369
|
+
}
|
|
6370
|
+
return schema;
|
|
6371
|
+
}) };
|
|
5652
6372
|
}
|
|
5653
6373
|
};
|
|
5654
6374
|
function generateSchema(ctx) {
|
|
@@ -6900,7 +7620,7 @@ function computePublicNames(appId) {
|
|
|
6900
7620
|
* (the `reg` registrations and the `sigScope` child), so passing the same scope
|
|
6901
7621
|
* the emitter continues with keeps later local registrations consistent.
|
|
6902
7622
|
*/
|
|
6903
|
-
function buildEmitModel(ctx, scope = new Scope(TS_RESERVED)) {
|
|
7623
|
+
function buildEmitModel$1(ctx, scope = new Scope(TS_RESERVED)) {
|
|
6904
7624
|
const appId = ctx.app?.id;
|
|
6905
7625
|
const pkg = ctx.package?.name ?? "unknown";
|
|
6906
7626
|
const publicNames = computePublicNames(appId);
|
|
@@ -6943,7 +7663,7 @@ function buildEmitModel(ctx, scope = new Scope(TS_RESERVED)) {
|
|
|
6943
7663
|
function generateTypeScript(ctx, packageScope) {
|
|
6944
7664
|
const cb = new CodeBuilder(" ");
|
|
6945
7665
|
const scope = packageScope ?? new Scope(TS_RESERVED);
|
|
6946
|
-
const { appId, pkg, names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel(ctx, scope);
|
|
7666
|
+
const { appId, pkg, names, rootType, rootIsStruct, namedTypes, typeDecls, rootTypeTag, paramsType, sigEntries } = buildEmitModel$1(ctx, scope);
|
|
6947
7667
|
cb.comment("This file was auto generated by Styx.");
|
|
6948
7668
|
cb.comment("Do not edit this file directly.");
|
|
6949
7669
|
cb.blank();
|
|
@@ -7126,7 +7846,7 @@ const tsDialect = {
|
|
|
7126
7846
|
* @param opts - Import and package-root options.
|
|
7127
7847
|
*/
|
|
7128
7848
|
function renderTypeScriptCall(ctx, config, opts = {}) {
|
|
7129
|
-
const model = buildEmitModel(ctx);
|
|
7849
|
+
const model = buildEmitModel$1(ctx);
|
|
7130
7850
|
const pkg = ctx.package?.name;
|
|
7131
7851
|
const fnName = model.rootIsStruct ? model.names.execute : model.names.wrapper;
|
|
7132
7852
|
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 +9011,10 @@ function compile(source, filenameOrOptions) {
|
|
|
8291
9011
|
exports.BoutiquesBackend = BoutiquesBackend;
|
|
8292
9012
|
exports.CodeBuilder = CodeBuilder;
|
|
8293
9013
|
exports.JsonSchemaBackend = JsonSchemaBackend;
|
|
9014
|
+
exports.NipypeBackend = NipypeBackend;
|
|
8294
9015
|
exports.PYTHON_RUNNER_DEPS = PYTHON_RUNNER_DEPS;
|
|
8295
9016
|
exports.PassStatus = PassStatus;
|
|
9017
|
+
exports.PydraBackend = PydraBackend;
|
|
8296
9018
|
exports.PythonBackend = PythonBackend;
|
|
8297
9019
|
exports.STYXDEFS_COMPAT = STYXDEFS_COMPAT;
|
|
8298
9020
|
exports.Scope = Scope;
|
|
@@ -8300,7 +9022,9 @@ exports.TypeScriptBackend = TypeScriptBackend;
|
|
|
8300
9022
|
exports.alt = alt;
|
|
8301
9023
|
exports.appEntrypoint = appEntrypoint;
|
|
8302
9024
|
exports.atomKey = atomKey;
|
|
9025
|
+
exports.buildEmitModel = buildEmitModel;
|
|
8303
9026
|
exports.buildSigEntries = buildSigEntries;
|
|
9027
|
+
exports.buildTypedSpec = buildTypedSpec;
|
|
8304
9028
|
exports.camelCase = camelCase;
|
|
8305
9029
|
exports.canonicalize = canonicalize;
|
|
8306
9030
|
exports.collectFieldInfo = collectFieldInfo;
|
|
@@ -8323,7 +9047,9 @@ exports.float = float;
|
|
|
8323
9047
|
exports.format = format;
|
|
8324
9048
|
exports.formatSolveResult = formatSolveResult;
|
|
8325
9049
|
exports.generateBoutiques = generateBoutiques;
|
|
9050
|
+
exports.generateNipype = generateNipype;
|
|
8326
9051
|
exports.generateOutputsSchema = generateOutputsSchema;
|
|
9052
|
+
exports.generatePydra = generatePydra;
|
|
8327
9053
|
exports.generatePython = generatePython;
|
|
8328
9054
|
exports.generateSchema = generateSchema;
|
|
8329
9055
|
exports.generateTypeScript = generateTypeScript;
|
|
@@ -8333,6 +9059,7 @@ exports.isIterated = isIterated;
|
|
|
8333
9059
|
exports.isStructural = isStructural;
|
|
8334
9060
|
exports.isTerminal = isTerminal;
|
|
8335
9061
|
exports.lit = lit;
|
|
9062
|
+
exports.nipypeNames = nipypeNames;
|
|
8336
9063
|
exports.nodeRef = nodeRef;
|
|
8337
9064
|
exports.opt = opt;
|
|
8338
9065
|
exports.outputGate = outputGate;
|
|
@@ -8340,6 +9067,7 @@ exports.pascalCase = pascalCase;
|
|
|
8340
9067
|
exports.path = path;
|
|
8341
9068
|
exports.planOutput = planOutput;
|
|
8342
9069
|
exports.planScope = planScope;
|
|
9070
|
+
exports.pydraNames = pydraNames;
|
|
8343
9071
|
exports.removeEmpty = removeEmpty;
|
|
8344
9072
|
exports.renderPythonCall = renderPythonCall;
|
|
8345
9073
|
exports.renderStructLiteral = renderStructLiteral;
|