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.
Files changed (35) hide show
  1. bocoflow_cli/__init__.py +8 -0
  2. bocoflow_cli/cli.py +248 -0
  3. bocoflow_cli/docs/README.md +13 -0
  4. bocoflow_cli/docs/multi-node-packages.md +46 -0
  5. bocoflow_cli/docs/node-package-structure.md +75 -0
  6. bocoflow_cli/docs/testing-and-loading-your-node.md +36 -0
  7. bocoflow_cli/docs.py +44 -0
  8. bocoflow_cli/scaffold.py +184 -0
  9. bocoflow_cli/templates/individual-node/README.md +37 -0
  10. bocoflow_cli/templates/individual-node/_TEMPLATE_USAGE.md +73 -0
  11. bocoflow_cli/templates/individual-node/core.py +49 -0
  12. bocoflow_cli/templates/individual-node/meta.toml +48 -0
  13. bocoflow_cli/templates/individual-node/node.py +136 -0
  14. bocoflow_cli/templates/individual-node/pixi.toml +44 -0
  15. bocoflow_cli/templates/individual-node/tests/test_node.py +86 -0
  16. bocoflow_cli/templates/multi-node-package/README.md +32 -0
  17. bocoflow_cli/templates/multi-node-package/_TEMPLATE_USAGE.md +74 -0
  18. bocoflow_cli/templates/multi-node-package/node_one/README.md +14 -0
  19. bocoflow_cli/templates/multi-node-package/node_one/core.py +37 -0
  20. bocoflow_cli/templates/multi-node-package/node_one/meta.toml +43 -0
  21. bocoflow_cli/templates/multi-node-package/node_one/node.py +97 -0
  22. bocoflow_cli/templates/multi-node-package/node_one/tests/test_node.py +64 -0
  23. bocoflow_cli/templates/multi-node-package/node_two/README.md +15 -0
  24. bocoflow_cli/templates/multi-node-package/node_two/core.py +20 -0
  25. bocoflow_cli/templates/multi-node-package/node_two/meta.toml +36 -0
  26. bocoflow_cli/templates/multi-node-package/node_two/node.py +101 -0
  27. bocoflow_cli/templates/multi-node-package/node_two/tests/test_node.py +72 -0
  28. bocoflow_cli/templates/multi-node-package/package.toml +63 -0
  29. bocoflow_cli/templates/multi-node-package/pixi.toml +46 -0
  30. bocoflow_cli/templates/multi-node-package/workflows/{{PACKAGE_NAME}}-pipeline.json +26 -0
  31. salpa_cli-0.1.2.dist-info/METADATA +79 -0
  32. salpa_cli-0.1.2.dist-info/RECORD +35 -0
  33. salpa_cli-0.1.2.dist-info/WHEEL +4 -0
  34. salpa_cli-0.1.2.dist-info/entry_points.txt +3 -0
  35. salpa_cli-0.1.2.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,8 @@
1
+ """BoCoFlow node authoring CLI."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("salpa-cli")
7
+ except PackageNotFoundError: # running from a source tree without an installed dist
8
+ __version__ = "0.0.0+dev"
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")
@@ -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