salpa-cli 0.1.2__py3-none-any.whl
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.
- bocoflow_cli/__init__.py +8 -0
- bocoflow_cli/cli.py +248 -0
- bocoflow_cli/docs/README.md +13 -0
- bocoflow_cli/docs/multi-node-packages.md +46 -0
- bocoflow_cli/docs/node-package-structure.md +75 -0
- bocoflow_cli/docs/testing-and-loading-your-node.md +36 -0
- bocoflow_cli/docs.py +44 -0
- bocoflow_cli/scaffold.py +184 -0
- bocoflow_cli/templates/individual-node/README.md +37 -0
- bocoflow_cli/templates/individual-node/_TEMPLATE_USAGE.md +73 -0
- bocoflow_cli/templates/individual-node/core.py +49 -0
- bocoflow_cli/templates/individual-node/meta.toml +48 -0
- bocoflow_cli/templates/individual-node/node.py +136 -0
- bocoflow_cli/templates/individual-node/pixi.toml +44 -0
- bocoflow_cli/templates/individual-node/tests/test_node.py +86 -0
- bocoflow_cli/templates/multi-node-package/README.md +32 -0
- bocoflow_cli/templates/multi-node-package/_TEMPLATE_USAGE.md +74 -0
- bocoflow_cli/templates/multi-node-package/node_one/README.md +14 -0
- bocoflow_cli/templates/multi-node-package/node_one/core.py +37 -0
- bocoflow_cli/templates/multi-node-package/node_one/meta.toml +43 -0
- bocoflow_cli/templates/multi-node-package/node_one/node.py +97 -0
- bocoflow_cli/templates/multi-node-package/node_one/tests/test_node.py +64 -0
- bocoflow_cli/templates/multi-node-package/node_two/README.md +15 -0
- bocoflow_cli/templates/multi-node-package/node_two/core.py +20 -0
- bocoflow_cli/templates/multi-node-package/node_two/meta.toml +36 -0
- bocoflow_cli/templates/multi-node-package/node_two/node.py +101 -0
- bocoflow_cli/templates/multi-node-package/node_two/tests/test_node.py +72 -0
- bocoflow_cli/templates/multi-node-package/package.toml +63 -0
- bocoflow_cli/templates/multi-node-package/pixi.toml +46 -0
- bocoflow_cli/templates/multi-node-package/workflows/{{PACKAGE_NAME}}-pipeline.json +26 -0
- salpa_cli-0.1.2.dist-info/METADATA +79 -0
- salpa_cli-0.1.2.dist-info/RECORD +35 -0
- salpa_cli-0.1.2.dist-info/WHEEL +4 -0
- salpa_cli-0.1.2.dist-info/entry_points.txt +3 -0
- salpa_cli-0.1.2.dist-info/licenses/LICENSE +202 -0
bocoflow_cli/__init__.py
ADDED
bocoflow_cli/cli.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""salpa — the Salpa node authoring CLI (also runnable as `bocoflow`).
|
|
2
|
+
|
|
3
|
+
v1 surface is `salpa new` (scaffold a node package). The `publish` / `install`
|
|
4
|
+
/ `run` verbs are reserved for later and intentionally not implemented yet.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.markdown import Markdown
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.prompt import Confirm, Prompt
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .docs import DocsError, available_docs, read_doc
|
|
20
|
+
from .scaffold import (
|
|
21
|
+
ScaffoldError,
|
|
22
|
+
ScaffoldSpec,
|
|
23
|
+
available_templates,
|
|
24
|
+
default_hashtags,
|
|
25
|
+
scaffold,
|
|
26
|
+
to_pascal_case,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
add_completion=False,
|
|
31
|
+
help="Salpa node authoring CLI. Scaffold node packages that match the "
|
|
32
|
+
"shipped template contract.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
_TEMPLATE_HELP = {
|
|
38
|
+
"individual-node": "one node, its own pixi env (the default)",
|
|
39
|
+
"multi-node-package": "several related nodes sharing one pixi env, chained",
|
|
40
|
+
"cloud-modal-node": "a cloud client stub",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Templates not shown in the interactive menu + --help. Still scaffoldable via an
|
|
44
|
+
# explicit `-t <name>`. Remove a name from this set to surface it in the menu.
|
|
45
|
+
_HIDDEN_TEMPLATES = {"cloud-modal-node"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ordered_templates() -> list[str]:
|
|
49
|
+
"""Available templates in a sensible order (individual-node first — the
|
|
50
|
+
default), not alphabetical, so the interactive menu's default (choice 1)
|
|
51
|
+
matches the --template flag default and isn't the niche cloud stub."""
|
|
52
|
+
order = list(_TEMPLATE_HELP)
|
|
53
|
+
avail = available_templates()
|
|
54
|
+
return sorted(avail, key=lambda t: order.index(t) if t in order else len(order))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _menu_templates() -> list[str]:
|
|
58
|
+
"""Templates offered in the interactive menu + --help — ordered, minus the hidden
|
|
59
|
+
ones. An explicit ``-t`` still accepts any available template."""
|
|
60
|
+
return [t for t in _ordered_templates() if t not in _HIDDEN_TEMPLATES]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _version_callback(value: bool) -> None:
|
|
64
|
+
if value:
|
|
65
|
+
console.print(f"salpa {__version__}")
|
|
66
|
+
raise typer.Exit()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback()
|
|
70
|
+
def main(
|
|
71
|
+
_version: bool = typer.Option(
|
|
72
|
+
False, "--version", callback=_version_callback, is_eager=True,
|
|
73
|
+
help="Show version and exit.",
|
|
74
|
+
),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Salpa node authoring CLI."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def new(
|
|
81
|
+
name: str = typer.Argument(None, help="Package name (kebab-case, e.g. my-analyzer)."),
|
|
82
|
+
template: str = typer.Option(
|
|
83
|
+
None, "--template", "-t",
|
|
84
|
+
help=f"Template: {', '.join(_menu_templates())}.",
|
|
85
|
+
),
|
|
86
|
+
description: str = typer.Option(None, "--description", "-d", help="One-line description."),
|
|
87
|
+
author: str = typer.Option(None, "--author", help="Author name."),
|
|
88
|
+
category: str = typer.Option(None, "--category", help="UI category, e.g. 'Cheminformatics'."),
|
|
89
|
+
hashtags: str = typer.Option(
|
|
90
|
+
None, "--hashtags", help="Comma-separated discovery hashtags, 5-10 (e.g. 'md,gromacs,protein').",
|
|
91
|
+
),
|
|
92
|
+
output: Path = typer.Option(
|
|
93
|
+
Path("."), "--output", "-o", help="Directory to create the package in.",
|
|
94
|
+
),
|
|
95
|
+
yes: bool = typer.Option(
|
|
96
|
+
False, "--yes", "-y", help="Non-interactive: accept defaults, no prompts.",
|
|
97
|
+
),
|
|
98
|
+
install: bool = typer.Option(
|
|
99
|
+
False, "--install", help="Run `pixi install` in the new package afterwards.",
|
|
100
|
+
),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Scaffold a new Salpa node package.
|
|
103
|
+
|
|
104
|
+
With no options it prompts interactively (create-next-app style); pass flags
|
|
105
|
+
and/or --yes to script it. The directory is created with underscores (the dir
|
|
106
|
+
name becomes the Python package name); meta.toml's name stays kebab-case.
|
|
107
|
+
"""
|
|
108
|
+
templates = _menu_templates()
|
|
109
|
+
|
|
110
|
+
if not yes:
|
|
111
|
+
if not name:
|
|
112
|
+
name = Prompt.ask("[bold]Package name[/] (kebab-case)")
|
|
113
|
+
if not template:
|
|
114
|
+
console.print("\n[bold]Template[/]:")
|
|
115
|
+
for i, t in enumerate(templates, 1):
|
|
116
|
+
console.print(f" {i}. [cyan]{t}[/] — {_TEMPLATE_HELP.get(t, '')}")
|
|
117
|
+
choice = Prompt.ask(
|
|
118
|
+
"Choose", choices=[str(i) for i in range(1, len(templates) + 1)], default="1",
|
|
119
|
+
)
|
|
120
|
+
template = templates[int(choice) - 1]
|
|
121
|
+
if description is None:
|
|
122
|
+
description = Prompt.ask("Short description", default="") or None
|
|
123
|
+
if author is None:
|
|
124
|
+
author = Prompt.ask("Author", default="TODO")
|
|
125
|
+
if category is None:
|
|
126
|
+
category = Prompt.ask("Category", default="TODO Category")
|
|
127
|
+
if hashtags is None:
|
|
128
|
+
suggested = ", ".join(default_hashtags(name, category or "TODO Category"))
|
|
129
|
+
hashtags = Prompt.ask("Hashtags (comma-separated, 5-10)", default=suggested)
|
|
130
|
+
if not install:
|
|
131
|
+
install = Confirm.ask("Run `pixi install` after scaffolding?", default=False)
|
|
132
|
+
|
|
133
|
+
if not name:
|
|
134
|
+
console.print("[red]A package name is required.[/] Pass it as an argument or drop --yes.")
|
|
135
|
+
raise typer.Exit(1)
|
|
136
|
+
|
|
137
|
+
tag_list = [t.strip() for t in hashtags.split(",") if t.strip()] if hashtags else None
|
|
138
|
+
|
|
139
|
+
spec = ScaffoldSpec(
|
|
140
|
+
name=name,
|
|
141
|
+
template=template or "individual-node",
|
|
142
|
+
description=description,
|
|
143
|
+
author=author or "TODO",
|
|
144
|
+
category=category or "TODO Category",
|
|
145
|
+
hashtags=tag_list,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
dest = scaffold(spec, output.resolve())
|
|
150
|
+
except ScaffoldError as e:
|
|
151
|
+
console.print(f"[red]Error:[/] {e}")
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
|
|
154
|
+
console.print(
|
|
155
|
+
Panel(
|
|
156
|
+
f"[green]Created[/] [bold]{dest}[/]\n"
|
|
157
|
+
f"class: [cyan]{to_pascal_case(name)}[/] template: [cyan]{spec.template}[/]",
|
|
158
|
+
title="salpa new",
|
|
159
|
+
border_style="green",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
_next_steps(spec.template, dest)
|
|
163
|
+
|
|
164
|
+
if install:
|
|
165
|
+
_pixi_install(dest, spec.template)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def docs(
|
|
170
|
+
name: str = typer.Argument(None, help="Doc to print (omit to list the bundled docs)."),
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Show the node-authoring docs bundled with this CLI."""
|
|
173
|
+
try:
|
|
174
|
+
if not name:
|
|
175
|
+
console.print(
|
|
176
|
+
"[bold]Bundled docs[/] — print one with [cyan]salpa docs <name>[/]:\n"
|
|
177
|
+
)
|
|
178
|
+
for doc in available_docs():
|
|
179
|
+
summary = ""
|
|
180
|
+
for line in read_doc(doc).splitlines():
|
|
181
|
+
if line.startswith("#"):
|
|
182
|
+
summary = line.lstrip("# ").strip()
|
|
183
|
+
break
|
|
184
|
+
console.print(f" [cyan]{doc}[/] — {summary}")
|
|
185
|
+
console.print(
|
|
186
|
+
"\nFull docs: [underline]https://salpa.app/docs/custom-nodes[/]"
|
|
187
|
+
)
|
|
188
|
+
return
|
|
189
|
+
console.print(Markdown(read_doc(name)))
|
|
190
|
+
except DocsError as e:
|
|
191
|
+
console.print(f"[red]{e}[/]")
|
|
192
|
+
raise typer.Exit(1)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _next_steps(template: str, dest: Path) -> None:
|
|
196
|
+
lines: list[str]
|
|
197
|
+
if template == "multi-node-package":
|
|
198
|
+
lines = [
|
|
199
|
+
"Rename node_one/ and node_two/ to real names (underscored dirs); keep each",
|
|
200
|
+
" meta.toml class_name in sync with its node.py class.",
|
|
201
|
+
"List every node in package.toml [package.nodes]; set a unique [package].name",
|
|
202
|
+
" and the same shared_environment in each node's meta.toml.",
|
|
203
|
+
"Put the science in each node's core.py; keep node.py a thin wrapper.",
|
|
204
|
+
"pixi install → pixi run test (core tests run; node tests skip — expected)",
|
|
205
|
+
]
|
|
206
|
+
elif template == "cloud-modal-node":
|
|
207
|
+
lines = [
|
|
208
|
+
"Edit meta.toml — description, hashtags, endpoint.",
|
|
209
|
+
"Point node.py at your deployed service (no pixi.toml needed).",
|
|
210
|
+
"Test against the deployed service.",
|
|
211
|
+
]
|
|
212
|
+
else:
|
|
213
|
+
lines = [
|
|
214
|
+
"Edit meta.toml — description, category, hashtags, license.",
|
|
215
|
+
"Put the science in core.py; keep node.py a thin wrapper.",
|
|
216
|
+
"Edit pixi.toml — declare only platforms your deps build for (pixi solves all).",
|
|
217
|
+
"pixi install → pixi run test (core tests run; node tests skip — expected)",
|
|
218
|
+
]
|
|
219
|
+
console.print("[bold]Next steps[/]:")
|
|
220
|
+
for i, line in enumerate(lines, 1):
|
|
221
|
+
# markup=False: these lines contain literal [package.nodes]/[package].name,
|
|
222
|
+
# which Rich would otherwise parse as style tags and eat.
|
|
223
|
+
console.print(f" {i}. {line}", markup=False)
|
|
224
|
+
# publish/install aren't CLI verbs yet (v1 is `new` only) — point at the bundled
|
|
225
|
+
# authoring docs so a newcomer isn't left guessing after scaffolding.
|
|
226
|
+
console.print(
|
|
227
|
+
"\n[dim]More: run `salpa docs` for the bundled authoring guides, or see "
|
|
228
|
+
"https://salpa.app/docs/custom-nodes[/dim]"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _pixi_install(dest: Path, template: str) -> None:
|
|
233
|
+
# Multi-node packages install their shared env from the package root; single
|
|
234
|
+
# node / cloud stub install in place. cloud-modal-node has no pixi.toml.
|
|
235
|
+
if template == "cloud-modal-node" or not (dest / "pixi.toml").exists():
|
|
236
|
+
console.print("[yellow]Skipping pixi install[/] (no pixi.toml in this template).")
|
|
237
|
+
return
|
|
238
|
+
console.print("Running [cyan]pixi install[/]…")
|
|
239
|
+
try:
|
|
240
|
+
subprocess.run(["pixi", "install"], cwd=dest, check=True)
|
|
241
|
+
except FileNotFoundError:
|
|
242
|
+
console.print("[yellow]pixi not found in PATH — skipped.[/]")
|
|
243
|
+
except subprocess.CalledProcessError as e:
|
|
244
|
+
console.print(f"[red]pixi install failed[/] (exit {e.returncode}).")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
app()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Salpa node authoring — bundled docs
|
|
2
|
+
|
|
3
|
+
These are the authoring docs that ship with the Salpa CLI. They cover what you need to take a node
|
|
4
|
+
from `salpa new` to a package that runs in Salpa. Print one with `salpa docs <name>`, or read
|
|
5
|
+
them here.
|
|
6
|
+
|
|
7
|
+
| Doc | What it covers |
|
|
8
|
+
|-----|----------------|
|
|
9
|
+
| [node-package-structure](node-package-structure.md) | The anatomy of a single node — `meta.toml`, `core.py`, `node.py`, `pixi.toml`, and the two-level split. |
|
|
10
|
+
| [multi-node-packages](multi-node-packages.md) | Bundling several related nodes that share one environment (`package.toml` + a shared env). |
|
|
11
|
+
| [testing-and-loading-your-node](testing-and-loading-your-node.md) | Running the tests, then loading a finished node into Salpa. |
|
|
12
|
+
|
|
13
|
+
Full docs, always up to date: <https://salpa.app/docs/custom-nodes>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Multi-node packages
|
|
2
|
+
|
|
3
|
+
When you have several related nodes that share the same dependencies, bundle them into one package
|
|
4
|
+
with a single shared environment instead of repeating the environment per node.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
salpa new my-suite -t multi-node-package
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Layout
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
my_suite/
|
|
14
|
+
├── package.toml # the manifest — its PRESENCE marks a multi-node package
|
|
15
|
+
├── pixi.toml # ONE shared environment for every node in the package
|
|
16
|
+
├── node_one/
|
|
17
|
+
│ ├── meta.toml # no [dependencies] block — deps live in the root pixi.toml
|
|
18
|
+
│ ├── core.py
|
|
19
|
+
│ └── node.py
|
|
20
|
+
├── node_two/
|
|
21
|
+
│ └── ...
|
|
22
|
+
└── workflows/ # an optional bundled example pipeline
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## `package.toml`
|
|
26
|
+
|
|
27
|
+
The manifest is what tells the registry and the app that this is a multi-node package (rather than a
|
|
28
|
+
single node). It carries:
|
|
29
|
+
|
|
30
|
+
- `[package]` — a unique `name` (kebab-case) and the list of nodes under `[package.nodes]`.
|
|
31
|
+
- a `shared_environment` name that **every** node's `meta.toml` must also declare — that's how the
|
|
32
|
+
nodes are wired to the one root environment.
|
|
33
|
+
|
|
34
|
+
## Shared environment
|
|
35
|
+
|
|
36
|
+
There is **one** `pixi.toml`, at the package root, with all the dependencies. Each node directory has
|
|
37
|
+
a `meta.toml` (with `shared_environment = "<name>"`) plus its own `core.py` / `node.py`, but **no**
|
|
38
|
+
per-node `pixi.toml`. Run `pixi install` + `pixi run test` once, from the package root.
|
|
39
|
+
|
|
40
|
+
To isolate a single node that has a conflicting dependency, give it its own `pixi.toml` and drop the
|
|
41
|
+
`shared_environment` line from that node's `meta.toml`.
|
|
42
|
+
|
|
43
|
+
## Next
|
|
44
|
+
|
|
45
|
+
- Node anatomy: [node-package-structure](node-package-structure.md)
|
|
46
|
+
- [Testing and loading your node](testing-and-loading-your-node.md)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Node package structure
|
|
2
|
+
|
|
3
|
+
A Salpa node is a small Python package — a directory (with an underscored name, so it imports
|
|
4
|
+
cleanly) that `salpa new` scaffolds for you:
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
protein_analyzer/
|
|
8
|
+
├── meta.toml # metadata the node registry reads to discover your node
|
|
9
|
+
├── core.py # the science — plain Python, testable on its own
|
|
10
|
+
├── node.py # a thin wrapper: parameters + an execute() that calls core.py
|
|
11
|
+
├── pixi.toml # an isolated, reproducible environment for your dependencies
|
|
12
|
+
├── README.md # what the node does
|
|
13
|
+
└── tests/ # a starting test suite
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## The two-level split
|
|
17
|
+
|
|
18
|
+
The single most important idea: **keep the science and the app integration separate.**
|
|
19
|
+
|
|
20
|
+
- **`core.py`** is pure Python. It knows nothing about Salpa — just functions that take inputs and
|
|
21
|
+
return outputs. You can unit-test it without the app installed.
|
|
22
|
+
- **`node.py`** is a thin wrapper. It declares the node's parameters, and its `execute()` reads those
|
|
23
|
+
parameters, calls into `core.py`, and shapes the result. It is the only file that imports the
|
|
24
|
+
runtime.
|
|
25
|
+
|
|
26
|
+
That split is why `pixi run test` can exercise `core.py` in a bare environment (the science has all
|
|
27
|
+
its deps) while the `node.py` tests skip (they need the running app) — expected, not a failure.
|
|
28
|
+
|
|
29
|
+
## `meta.toml`
|
|
30
|
+
|
|
31
|
+
The registry reads this file to discover your node. Key sections:
|
|
32
|
+
|
|
33
|
+
- `[package]` — `name` (kebab-case, globally unique), `version`, `description`, `author`, and
|
|
34
|
+
`license` (**required** — a node with no license is skipped at load and never shows in the palette).
|
|
35
|
+
- `[node]` — `class_name` (must match the class in `node.py`), `display_name`, `num_in` / `num_out`
|
|
36
|
+
(input/output ports), and `category`.
|
|
37
|
+
- `hashtags` — 5–10 lowercase-with-hyphens tags that drive discovery. Be specific.
|
|
38
|
+
- `[node.metadata]` — an emoji `icon` and a short `tooltip`.
|
|
39
|
+
- `[node.features]` — flags like `requires_gpu`, `long_running`.
|
|
40
|
+
|
|
41
|
+
## `node.py`
|
|
42
|
+
|
|
43
|
+
Your node is a subclass of `Node` from the runtime:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from bocoflow_core.node import Node, NodeException, NodeResult
|
|
47
|
+
from bocoflow_core.parameters import TextParameter, FileParameterEdit, FolderParameter
|
|
48
|
+
# Also available: StringParameter, IntegerParameter, FloatParameter, BooleanParameter, SelectParameter
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Two things to fill in:
|
|
52
|
+
|
|
53
|
+
- **`OPTIONS`** — a dict of the parameters the user sees in the UI. Each value is a `Parameter`.
|
|
54
|
+
(Don't add `force_to_run` — it's inherited.)
|
|
55
|
+
- **`execute(self, predecessor_data, flow_vars)`** — read parameters with
|
|
56
|
+
`flow_vars["name"].get_value()`, call your `core.py`, write any output files, and return a
|
|
57
|
+
`NodeResult` as JSON (`result.to_json()`). Raise `NodeException(stage, message)` on failure.
|
|
58
|
+
|
|
59
|
+
`predecessor_data` is the list of result dicts from upstream nodes, so a node can carry data forward
|
|
60
|
+
through a pipeline.
|
|
61
|
+
|
|
62
|
+
## `pixi.toml`
|
|
63
|
+
|
|
64
|
+
Defines the node's isolated environment (via [pixi](https://pixi.sh/)):
|
|
65
|
+
|
|
66
|
+
- Declare **only** the platforms your dependencies build for — pixi solves all of them.
|
|
67
|
+
- Put dependencies under `[dependencies]` (prefer conda-forge). Keep `pytest` in the default
|
|
68
|
+
environment so `pixi run test` works, and keep `redis-py` so live progress logs reach the UI.
|
|
69
|
+
- Do **not** add `bocoflow_core` here — the Salpa runtime provides it. That's exactly why
|
|
70
|
+
`pixi run test` can import `core.py` but not `node.py`.
|
|
71
|
+
|
|
72
|
+
## Next
|
|
73
|
+
|
|
74
|
+
- [Testing and loading your node](testing-and-loading-your-node.md)
|
|
75
|
+
- Several related nodes? See [multi-node packages](multi-node-packages.md).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Testing and loading your node
|
|
2
|
+
|
|
3
|
+
## Test it
|
|
4
|
+
|
|
5
|
+
From the package directory (or the package root, for a multi-node package):
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pixi install # build the isolated environment
|
|
9
|
+
pixi run test # run the tests
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
You'll see the `core.py` tests pass and the `node.py` tests **skip** — the node tests need the
|
|
13
|
+
running Salpa app, so they're expected to skip in this bare environment (`pixi run test` prints the
|
|
14
|
+
skip reasons). A green `core.py` suite means your science is sound.
|
|
15
|
+
|
|
16
|
+
Write your tests in `tests/`, and keep testing `core.py` directly — that's the fast, reliable loop.
|
|
17
|
+
|
|
18
|
+
## Load it into Salpa
|
|
19
|
+
|
|
20
|
+
Once the tests are green:
|
|
21
|
+
|
|
22
|
+
1. Open Salpa and go to the node library.
|
|
23
|
+
2. Use **Import** (Local Source) and point it at your package directory.
|
|
24
|
+
3. Salpa reads `meta.toml`, installs the node's environment, and the node appears in the palette
|
|
25
|
+
under its `category`.
|
|
26
|
+
4. Drop it on the canvas and run it.
|
|
27
|
+
|
|
28
|
+
If you change the node, re-import (or refresh) to pick up the changes.
|
|
29
|
+
|
|
30
|
+
## Iterate
|
|
31
|
+
|
|
32
|
+
- Edit `core.py`, re-run `pixi run test`.
|
|
33
|
+
- Adjust parameters or the result shape in `node.py`.
|
|
34
|
+
- Update `meta.toml` (description, hashtags, category) so the node is easy to find.
|
|
35
|
+
|
|
36
|
+
Full, always-current docs: <https://salpa.app/docs/custom-nodes>
|
bocoflow_cli/docs.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Locate + read the node-authoring docs bundled with the CLI.
|
|
2
|
+
|
|
3
|
+
Same dev/installed resolution as ``scaffold.templates_dir()``: the docs live at the repo root
|
|
4
|
+
(``docs/``) for dev runs, and are force-included into the wheel under ``bocoflow_cli/docs`` so
|
|
5
|
+
``importlib.resources`` finds them once pip-installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.resources as ir
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DocsError(Exception):
|
|
15
|
+
"""The bundled docs could not be located, or a requested doc was not found."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def docs_dir() -> Path:
|
|
19
|
+
"""Locate the bundled docs in dev (repo root) and installed (wheel) layouts."""
|
|
20
|
+
repo = Path(__file__).resolve().parents[2] / "docs"
|
|
21
|
+
if repo.is_dir():
|
|
22
|
+
return repo
|
|
23
|
+
packaged = ir.files("bocoflow_cli") / "docs"
|
|
24
|
+
if packaged.is_dir(): # type: ignore[union-attr]
|
|
25
|
+
return Path(str(packaged))
|
|
26
|
+
raise DocsError("Bundled docs not found (looked in repo root and package data).")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def available_docs() -> list[str]:
|
|
30
|
+
"""Doc names (without the .md suffix) — README first, then alphabetical."""
|
|
31
|
+
names = sorted(p.stem for p in docs_dir().glob("*.md"))
|
|
32
|
+
if "README" in names:
|
|
33
|
+
names.remove("README")
|
|
34
|
+
names.insert(0, "README")
|
|
35
|
+
return names
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_doc(name: str) -> str:
|
|
39
|
+
"""Return the text of a bundled doc. ``name`` may omit the .md suffix."""
|
|
40
|
+
stem = name[:-3] if name.endswith(".md") else name
|
|
41
|
+
path = docs_dir() / f"{stem}.md"
|
|
42
|
+
if not path.is_file():
|
|
43
|
+
raise DocsError(f"No doc named {name!r}. Available: {', '.join(available_docs())}.")
|
|
44
|
+
return path.read_text(encoding="utf-8")
|
bocoflow_cli/scaffold.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Scaffold a Salpa node package from a bundled template.
|
|
2
|
+
|
|
3
|
+
Pure logic — no Typer/Rich here so it stays unit-testable. Guarantees:
|
|
4
|
+
underscored directory names (the directory becomes the Python package name, so
|
|
5
|
+
a hyphen breaks `from .core import ...`), every placeholder substituted (an
|
|
6
|
+
unlisted one is a hard error, not a silently-broken package), placeholder-named
|
|
7
|
+
files renamed, and the template's own usage doc stripped.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.resources as ir
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
TEMPLATES = ("individual-node", "multi-node-package", "cloud-modal-node")
|
|
19
|
+
|
|
20
|
+
_KEBAB = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
21
|
+
_SUBST_SUFFIXES = {".toml", ".py", ".md", ".json"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ScaffoldError(Exception):
|
|
25
|
+
"""A scaffold could not be produced (bad name, missing template, etc.)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def templates_dir() -> Path:
|
|
29
|
+
"""Locate the bundled templates in dev and installed layouts.
|
|
30
|
+
|
|
31
|
+
Dev/repo run: <repo>/templates (this file is src/bocoflow_cli/scaffold.py).
|
|
32
|
+
Installed wheel: force-included under the package as bocoflow_cli/templates.
|
|
33
|
+
"""
|
|
34
|
+
repo = Path(__file__).resolve().parents[2] / "templates"
|
|
35
|
+
if repo.is_dir():
|
|
36
|
+
return repo
|
|
37
|
+
packaged = ir.files("bocoflow_cli") / "templates"
|
|
38
|
+
if packaged.is_dir(): # type: ignore[union-attr]
|
|
39
|
+
return Path(str(packaged))
|
|
40
|
+
raise ScaffoldError("Bundled templates not found (looked in repo root and package data).")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def available_templates() -> list[str]:
|
|
44
|
+
root = templates_dir()
|
|
45
|
+
return sorted(p.name for p in root.iterdir() if p.is_dir())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def to_pascal_case(kebab: str) -> str:
|
|
49
|
+
return "".join(part[:1].upper() + part[1:] for part in kebab.split("-"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def to_title_case(kebab: str) -> str:
|
|
53
|
+
return " ".join(part[:1].upper() + part[1:] for part in kebab.split("-"))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def default_hashtags(name: str, category: str) -> list[str]:
|
|
57
|
+
"""A sensible starter hashtag list — the name's words plus the category slug.
|
|
58
|
+
|
|
59
|
+
Beats the old ``[name, "todo-domain"]`` (which shipped a literal TODO), and
|
|
60
|
+
is what the interactive prompt offers as its default. Still meant to be
|
|
61
|
+
expanded toward the 5-10 the template comment recommends.
|
|
62
|
+
"""
|
|
63
|
+
tags = [part for part in name.split("-") if part]
|
|
64
|
+
slug = category.strip().lower().replace(" ", "-")
|
|
65
|
+
if slug and slug not in {"todo-category", "todo"} and slug not in tags:
|
|
66
|
+
tags.append(slug)
|
|
67
|
+
return tags
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_name(name: str) -> None:
|
|
71
|
+
if not _KEBAB.match(name):
|
|
72
|
+
raise ScaffoldError(
|
|
73
|
+
f"Node name must be kebab-case (lowercase, hyphens): got {name!r}.\n"
|
|
74
|
+
" Valid: my-new-node, protein-analyzer\n"
|
|
75
|
+
" Invalid: MyNode, my_node, 123-node"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ScaffoldSpec:
|
|
81
|
+
name: str # kebab-case package name
|
|
82
|
+
template: str = "individual-node"
|
|
83
|
+
description: str | None = None
|
|
84
|
+
author: str = "TODO"
|
|
85
|
+
category: str = "TODO Category"
|
|
86
|
+
hashtags: list[str] | None = None # None → derived from name + category
|
|
87
|
+
|
|
88
|
+
def substitutions(self) -> dict[str, str]:
|
|
89
|
+
cls = to_pascal_case(self.name)
|
|
90
|
+
disp = to_title_case(self.name)
|
|
91
|
+
desc = self.description or f"TODO: one-line description of {self.name}"
|
|
92
|
+
dir_name = self.name.replace("-", "_")
|
|
93
|
+
tags = self.hashtags or default_hashtags(self.name, self.category)
|
|
94
|
+
# Every placeholder any template uses must appear here — an unlisted one is
|
|
95
|
+
# copied through literally and caught by the guard in `scaffold`.
|
|
96
|
+
return {
|
|
97
|
+
"PACKAGE_NAME": self.name,
|
|
98
|
+
"CLASS_NAME": cls,
|
|
99
|
+
"DISPLAY_NAME": disp,
|
|
100
|
+
"DESCRIPTION": desc,
|
|
101
|
+
"PACKAGE_DESCRIPTION": desc,
|
|
102
|
+
"AUTHOR": self.author,
|
|
103
|
+
"GITHUB_USER": "your-org",
|
|
104
|
+
"CATEGORY": self.category,
|
|
105
|
+
# Expands inside a TOML/JSON list: hashtags = [{{HASHTAGS}}] →
|
|
106
|
+
# hashtags = ["a", "b", "c"]. Value is the quoted, comma-joined body.
|
|
107
|
+
"HASHTAGS": ", ".join(f'"{t}"' for t in tags),
|
|
108
|
+
"ENDPOINT_SLUG": self.name,
|
|
109
|
+
"SHARED_ENV": dir_name,
|
|
110
|
+
"NODE1_CLASS_NAME": f"{cls}One",
|
|
111
|
+
"NODE1_DISPLAY_NAME": f"{disp} One",
|
|
112
|
+
"NODE1_DESCRIPTION": "TODO: what the first node does",
|
|
113
|
+
"NODE2_CLASS_NAME": f"{cls}Two",
|
|
114
|
+
"NODE2_DISPLAY_NAME": f"{disp} Two",
|
|
115
|
+
"NODE2_DESCRIPTION": "TODO: what the second node does",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _apply_substitutions(text: str, subs: dict[str, str]) -> str:
|
|
120
|
+
return re.sub(
|
|
121
|
+
r"\{\{([A-Z0-9_]+)\}\}",
|
|
122
|
+
lambda m: subs.get(m.group(1), m.group(0)),
|
|
123
|
+
text,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def scaffold(spec: ScaffoldSpec, out_dir: Path) -> Path:
|
|
128
|
+
"""Create the package under out_dir/<under_scored_name>/ and return its path."""
|
|
129
|
+
validate_name(spec.name)
|
|
130
|
+
if spec.template not in available_templates():
|
|
131
|
+
raise ScaffoldError(
|
|
132
|
+
f"Unknown template {spec.template!r}. Available: {', '.join(available_templates())}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
src = templates_dir() / spec.template
|
|
136
|
+
dir_name = spec.name.replace("-", "_") # underscores — see module docstring
|
|
137
|
+
dest = out_dir / dir_name
|
|
138
|
+
if dest.exists():
|
|
139
|
+
raise ScaffoldError(f"Destination already exists: {dest}")
|
|
140
|
+
|
|
141
|
+
# Never carry template build junk into a scaffold. If someone ran Python or
|
|
142
|
+
# pixi inside a template dir, a stray .pyc (compiled from a template core.py)
|
|
143
|
+
# still holds the {{PLACEHOLDER}} docstring bytes and would trip the guard
|
|
144
|
+
# below / ship in the wheel. (Found by the new-user trial, 2026-07-18.)
|
|
145
|
+
shutil.copytree(
|
|
146
|
+
src, dest,
|
|
147
|
+
ignore=shutil.ignore_patterns(
|
|
148
|
+
"__pycache__", "*.pyc", ".pixi", "pixi.lock", ".pytest_cache",
|
|
149
|
+
".coverage", ".DS_Store",
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
(dest / "_TEMPLATE_USAGE.md").unlink(missing_ok=True)
|
|
153
|
+
|
|
154
|
+
subs = spec.substitutions()
|
|
155
|
+
|
|
156
|
+
# Substitute file contents.
|
|
157
|
+
for path in dest.rglob("*"):
|
|
158
|
+
if path.is_file() and path.suffix in _SUBST_SUFFIXES:
|
|
159
|
+
path.write_text(_apply_substitutions(path.read_text(encoding="utf-8"), subs),
|
|
160
|
+
encoding="utf-8")
|
|
161
|
+
|
|
162
|
+
# Rename placeholder-named files (e.g. workflows/{{PACKAGE_NAME}}-pipeline.json).
|
|
163
|
+
# Deepest-first so a renamed parent can't invalidate a child path mid-loop.
|
|
164
|
+
for path in sorted(dest.rglob("*{{*}}*"), key=lambda p: len(p.parts), reverse=True):
|
|
165
|
+
new = path.parent / _apply_substitutions(path.name, subs)
|
|
166
|
+
if new != path:
|
|
167
|
+
path.rename(new)
|
|
168
|
+
|
|
169
|
+
# Fail loudly rather than emit a half-substituted package.
|
|
170
|
+
leftover: set[str] = set()
|
|
171
|
+
for path in dest.rglob("*"):
|
|
172
|
+
if path.is_file() and path.suffix in _SUBST_SUFFIXES:
|
|
173
|
+
leftover |= set(re.findall(r"\{\{[A-Z0-9_]+\}\}", path.read_text(encoding="utf-8")))
|
|
174
|
+
if "{{" in path.name:
|
|
175
|
+
leftover.add(path.name)
|
|
176
|
+
if leftover:
|
|
177
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
178
|
+
raise ScaffoldError(
|
|
179
|
+
"Unsubstituted placeholders remained: "
|
|
180
|
+
+ ", ".join(sorted(leftover))
|
|
181
|
+
+ "\nAdd them to ScaffoldSpec.substitutions()."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return dest
|