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,37 @@
1
+ # {{DISPLAY_NAME}} (BoCoFlow node)
2
+
3
+ {{DESCRIPTION}} Wraps [<tool>](<url>) (<license>).
4
+
5
+ ## What it does
6
+
7
+ One or two sentences: what goes in, what comes out, and why it matters. Say
8
+ where this sits in a pipeline — what normally runs before it and after it.
9
+
10
+ ## Inputs
11
+
12
+ - **Input (one record per line)** — paste records directly, **or**
13
+ - **Input File** — a file with one record per line, **or**
14
+ - records carried from a predecessor node under `data["processed"]` (chainable).
15
+
16
+ ## Parameters
17
+
18
+ | Option | Default | Meaning |
19
+ |--------|---------|---------|
20
+ | ... | ... | ... |
21
+
22
+ ## Output
23
+
24
+ - `data["processed"]` — the processed records (forwarded to the next node)
25
+ - `data["n_in"]` / `data["n_out"]` — record counts
26
+ - `output.txt` in the output directory
27
+
28
+ ## Deployment
29
+
30
+ **Local (PIXI_SUBPROCESS)** — CPU-only. Deps: list them, and say which channel
31
+ (conda-forge / bioconda) or that they are pip-only.
32
+
33
+ ## Status
34
+
35
+ Say plainly what has and has not been verified — e.g. "`pixi install` +
36
+ `pixi run test` green; node.py wiring confirmed by running the node on a
37
+ canvas in Salpa." A reader needs to know what they can trust.
@@ -0,0 +1,73 @@
1
+ # individual-node — template usage
2
+
3
+ A single node with its own pixi environment. The default starting point. For
4
+ several related nodes sharing one environment, use `multi-node-package/`.
5
+
6
+ > This file is removed by the scaffolder — it never lands in a generated package.
7
+ > `README.md` is the *node's* README (the registry serves it), so write that one
8
+ > for the people who will use your node.
9
+
10
+ ## Scaffold it
11
+
12
+ ```bash
13
+ salpa new my-analyzer
14
+ ```
15
+
16
+ Creates `my_analyzer/` — **underscores**, because the directory name becomes the
17
+ Python package name, so a hyphen would break `from .core import ...`. Meanwhile
18
+ `[package].name` inside `meta.toml` stays **kebab-case** (`my-analyzer`). Both
19
+ spellings are correct; they are different things.
20
+
21
+ ## Placeholders
22
+
23
+ | Placeholder | Example |
24
+ |---|---|
25
+ | `{{PACKAGE_NAME}}` | `my-analyzer` (kebab — goes in meta.toml) |
26
+ | `{{CLASS_NAME}}` | `MyAnalyzer` (PascalCase — must match `meta.toml` `class_name`) |
27
+ | `{{DISPLAY_NAME}}` | `My Analyzer` |
28
+ | `{{DESCRIPTION}}` | `Analyzes trajectory files` |
29
+ | `{{AUTHOR}}` | your name or organization |
30
+ | `{{CATEGORY}}` | `Cheminformatics`, `MD Analysis`, `Structure Prep`, ... |
31
+ | `{{HASHTAGS}}` | discovery hashtags, quoted+comma-joined (aim for 5-10) |
32
+
33
+ ## The two-level split — the thing to understand
34
+
35
+ | File | Imports `bocoflow_core`? | Importable by `pixi run test`? |
36
+ |---|---|---|
37
+ | `core.py` | **no** — pure Python | **yes** — this is what your tests validate |
38
+ | `node.py` | yes | **no** — the runtime provides `bocoflow_core` |
39
+
40
+ `bocoflow_core` is supplied by the Salpa runtime and is deliberately not a pixi
41
+ dependency, so `pixi run test` can only exercise `core.py`. Put the science in
42
+ `core.py`; keep `node.py` a thin wrapper. Validate `node.py` by loading the node
43
+ into Salpa and running it on a canvas.
44
+
45
+ This is why `tests/test_node.py` guards the node import per-test rather than with a
46
+ module-level skip — a module-level skip would skip the pure core tests too, and
47
+ `pixi run test` would exit 0 having validated nothing.
48
+
49
+ ## Then
50
+
51
+ ```bash
52
+ cd my_analyzer
53
+ pixi install # first real test of your pixi.toml
54
+ pixi run test # runs the core tests; node tests skip (expected)
55
+ ```
56
+
57
+ Declare only the platforms your dependencies actually build for — pixi solves
58
+ **all** declared platforms, so an over-declared `win-64` fails the solve for a dep
59
+ that has no Windows build.
60
+
61
+ Then load the node into Salpa (node library → Import, Local Source) and run it.
62
+ See `salpa docs testing-and-loading-your-node`.
63
+
64
+ ## Checklist
65
+
66
+ - [ ] `meta.toml` `class_name` matches the class in `node.py`
67
+ - [ ] `[package].license` is set to the real license of the tool you wrap
68
+ (a missing license means the node silently never registers)
69
+ - [ ] 5-10 specific hashtags
70
+ - [ ] `pixi install` solves; `pixi run test` reports **passed** core tests, not
71
+ "all skipped"
72
+ - [ ] `stream_log(..., node_id=self.node_id, progress=N)` at 0 and 100
73
+ - [ ] the node runs on a real canvas
@@ -0,0 +1,49 @@
1
+ """Pure-Python core for {{PACKAGE_NAME}}.
2
+
3
+ The science lives here; node.py is a thin BoCoFlow wrapper around it. Keeping
4
+ the two apart is what makes this file unit-testable on its own: a bare pixi env
5
+ can import core.py but NOT node.py (node.py needs bocoflow_core, which the
6
+ runtime provides and is deliberately not a pixi dependency).
7
+
8
+ No BoCoFlow imports here — pure Python, unit-testable standalone.
9
+
10
+ Heavy dependencies (rdkit, MDAnalysis, ...) should be imported INSIDE the
11
+ function that needs them, not at module scope, so the pure helpers below stay
12
+ importable even when the heavy dep is missing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Dict, List
18
+
19
+
20
+ def read_records(text: str = "", file_path: str = "") -> List[str]:
21
+ """Collect input records from a text block (one per line) and/or a file.
22
+
23
+ Note: file reads pass encoding="utf-8" — bare open()/read_text() default to
24
+ cp1252 on Windows and silently corrupt non-ASCII.
25
+ """
26
+ records: List[str] = []
27
+ if text:
28
+ records += [ln.strip() for ln in text.splitlines() if ln.strip()]
29
+ if file_path:
30
+ with open(file_path, encoding="utf-8") as fh:
31
+ records += [ln.strip() for ln in fh if ln.strip()]
32
+ return records
33
+
34
+
35
+ def process(records: List[str]) -> Dict:
36
+ """TODO: replace this with your real computation.
37
+
38
+ The default implementation is a placeholder that simply reports what it was
39
+ given, so the template runs green out of the box. Return a plain dict — the
40
+ node wrapper is responsible for turning it into a NodeResult.
41
+ """
42
+ # --- Replace from here ------------------------------------------------
43
+ processed = [r.upper() for r in records]
44
+ # --- to here ----------------------------------------------------------
45
+ return {
46
+ "processed": processed,
47
+ "n_in": len(records),
48
+ "n_out": len(processed),
49
+ }
@@ -0,0 +1,48 @@
1
+ # Node package metadata — the registry reads THIS file to discover your node.
2
+ # Replace every double-brace placeholder below.
3
+ #
4
+ # Guide: https://salpa.app/docs/custom-nodes (also bundled — run `salpa docs`)
5
+
6
+ [package]
7
+ # kebab-case, globally unique (the registry keys on it and collisions silently
8
+ # overwrite). The DIRECTORY uses underscores. Keep this line comment-free — some
9
+ # tooling parses it literally.
10
+ name = "{{PACKAGE_NAME}}"
11
+ version = "0.1.0"
12
+ description = "{{DESCRIPTION}}"
13
+ author = "{{AUTHOR}}"
14
+ license = "MIT" # REQUIRED — a node with no license is skipped at
15
+ # load time and silently never appears in the
16
+ # palette. Set the real license of the tool you wrap.
17
+ bocoflow_core_version = ">=2.0.0"
18
+
19
+ [node]
20
+ class_name = "{{CLASS_NAME}}" # REQUIRED — must match the class in node.py exactly
21
+ display_name = "{{DISPLAY_NAME}}" # REQUIRED — shown in the UI
22
+ num_in = 1 # input ports (use 0 for a source node)
23
+ num_out = 1 # output ports
24
+ category = "{{CATEGORY}}" # e.g. "Cheminformatics", "MD Analysis", "Structure Prep"
25
+
26
+ # Hashtags drive discovery — use 5-10, lowercase-with-hyphens, and be specific
27
+ # ("gromacs-mdrun", not "simulation"). Cover: what it does, the tool it wraps,
28
+ # the scientific domain, and the input/output types.
29
+ hashtags = [{{HASHTAGS}}]
30
+
31
+ [node.metadata]
32
+ icon = "📦" # an emoji — real packages use emoji, not SVG
33
+ tooltip = "{{DESCRIPTION}}" # short behavioural one-liner; keep it distinct
34
+ # from description, e.g. "SMILES → protonation
35
+ # states at a pH window (1→N protomers)"
36
+
37
+ [node.features]
38
+ supports_resume = false
39
+ requires_gpu = false
40
+ parallel_capable = false
41
+ long_running = false # affects UI behaviour for slow nodes
42
+ # default_timeout = 3600 # seconds; omit for the default
43
+
44
+ [dependencies]
45
+ python = ">=3.10" # Runtime deps belong in pixi.toml, NOT here.
46
+
47
+ [documentation]
48
+ readme = "README.md"
@@ -0,0 +1,136 @@
1
+ """{{PACKAGE_NAME}} — {{DESCRIPTION}}
2
+
3
+ BoCoFlow node wrapper. The computation itself lives in core.py; this file only
4
+ handles BoCoFlow I/O — reading parameters, resolving paths, streaming progress,
5
+ and shaping the NodeResult.
6
+ """
7
+
8
+ import os
9
+
10
+ from bocoflow_core.node import Node, NodeException, NodeResult
11
+ from bocoflow_core.parameters import (
12
+ FileParameterEdit,
13
+ FolderParameter,
14
+ TextParameter,
15
+ # Also available: StringParameter, IntegerParameter, FloatParameter,
16
+ # BooleanParameter, SelectParameter
17
+ )
18
+ from bocoflow_core.stream_logger import stream_log
19
+
20
+ # Standard 3-stage import. Do not replace this with a plain `from .core import` —
21
+ # each stage covers a real context, and stage 3 is what lets the SERVER import
22
+ # node.py (to read OPTIONS for the UI) without your heavy deps installed.
23
+ try:
24
+ from .core import process, read_records # package context (pytest/direct)
25
+ except ImportError:
26
+ try:
27
+ from core import process, read_records # node_runner injects node_dir on path
28
+ except ImportError:
29
+ process = read_records = None # server env: heavy deps absent
30
+
31
+
32
+ def _merge_predecessors(predecessor_data):
33
+ """Flatten upstream node `data` dicts into one carry-forward dict."""
34
+ merged = {}
35
+ for pred in (predecessor_data or []):
36
+ if not isinstance(pred, dict):
37
+ continue
38
+ scope = pred.get("data") if isinstance(pred.get("data"), dict) else pred
39
+ for k, v in scope.items():
40
+ if k not in ("success", "message", "metadata", "files"):
41
+ merged[k] = v
42
+ return merged
43
+
44
+
45
+ class {{CLASS_NAME}}(Node):
46
+ """{{DESCRIPTION}}"""
47
+
48
+ # Mirrors meta.toml. The registry reads meta.toml; these attributes are the
49
+ # in-code fallback and keep node.py readable on its own.
50
+ category = "{{CATEGORY}}"
51
+ tags = [{{HASHTAGS}}]
52
+
53
+ # NOTE: do NOT add force_to_run — it is inherited from Node.BASE_OPTIONS.
54
+ OPTIONS = {
55
+ "text_input": TextParameter(
56
+ "Input (one record per line)", default="",
57
+ docstring="Records to process, one per line. Leave empty to use an "
58
+ "input file or data carried from a predecessor node.",
59
+ ),
60
+ "input_file": FileParameterEdit(
61
+ "Input File (optional)",
62
+ docstring="Optional input file, one record per line.",
63
+ ),
64
+ "output_dir": FolderParameter(
65
+ "Output Directory",
66
+ docstring="Directory where output files will be written.",
67
+ ),
68
+ # --- Add your node-specific parameters below ---
69
+ }
70
+
71
+ def execute(self, predecessor_data, flow_vars):
72
+ """Run the node.
73
+
74
+ Args:
75
+ predecessor_data: list of result dicts from upstream nodes (or None).
76
+ flow_vars: dict of parameter name -> Parameter. Read with .get_value().
77
+
78
+ Returns:
79
+ str: JSON from NodeResult.to_json()
80
+ """
81
+ stream_log("Starting {{CLASS_NAME}}...", node_id=self.node_id, progress=0)
82
+ try:
83
+ # Stage-3 import landed (see above) — the real deps are missing.
84
+ if process is None:
85
+ raise NodeException(
86
+ "setup",
87
+ "core.py could not be imported. Check that this node's pixi "
88
+ "environment installed its dependencies (`pixi install`).",
89
+ )
90
+
91
+ carried = _merge_predecessors(predecessor_data)
92
+ result = NodeResult()
93
+
94
+ text = flow_vars["text_input"].get_value() or ""
95
+ file_val = flow_vars["input_file"].get_value()
96
+ file_path = self.resolve_path(file_val) if file_val else ""
97
+
98
+ records = read_records(text, file_path)
99
+ # Fall back to records carried from an upstream node.
100
+ if not records and carried.get("processed"):
101
+ upstream = carried["processed"]
102
+ records = upstream if isinstance(upstream, list) else [upstream]
103
+ if not records:
104
+ raise NodeException(
105
+ "input",
106
+ "No input provided (via text, file, or a predecessor node).",
107
+ )
108
+
109
+ stream_log(
110
+ f"Processing {len(records)} record(s)...",
111
+ node_id=self.node_id, progress=40,
112
+ )
113
+ out = process(records)
114
+
115
+ out_dir = self.resolve_path(flow_vars["output_dir"].get_value())
116
+ os.makedirs(out_dir, exist_ok=True)
117
+ out_file = os.path.join(out_dir, "output.txt")
118
+ with open(out_file, "w", encoding="utf-8") as fh:
119
+ for record in out["processed"]:
120
+ fh.write(f"{record}\n")
121
+
122
+ result.data = {
123
+ **carried, # carry upstream keys forward
124
+ **out, # this node's own outputs
125
+ "output_file": self.format_output_path(out_file),
126
+ }
127
+ result.files["output"] = {"output_file": result.data["output_file"]}
128
+ result.success = True
129
+ result.message = f"{out['n_in']} record(s) in → {out['n_out']} out"
130
+ stream_log(result.message, node_id=self.node_id, progress=100)
131
+ return result.to_json()
132
+
133
+ except NodeException:
134
+ raise # keep the original stage tag
135
+ except Exception as e:
136
+ raise NodeException("execution", str(e))
@@ -0,0 +1,44 @@
1
+ # Pixi Environment Definition
2
+ # This file defines the isolated environment for this node
3
+ # See: https://pixi.sh/
4
+
5
+ [workspace]
6
+ name = "{{PACKAGE_NAME}}"
7
+ version = "0.1.0"
8
+ description = "{{DESCRIPTION}}"
9
+ channels = ["conda-forge"]
10
+ # Declare ONLY platforms your deps actually build for (pixi solves ALL of them).
11
+ # Many bioconda / specialized packages lack win-64 (and sometimes osx-arm64) — trim as needed.
12
+ platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"]
13
+
14
+ [dependencies]
15
+ # Core Python requirement
16
+ python = ">=3.10,<3.13"
17
+
18
+ # Redis client for real-time log streaming from subprocess to UI.
19
+ # Keeps stream_log() calls in your node appearing in the UI immediately.
20
+ redis-py = ">=5.0.0"
21
+
22
+ # pytest MUST be in the default env (not just [feature.dev]) so `pixi run test` works
23
+ pytest = ">=7.0"
24
+
25
+ # Add your conda-forge dependencies here
26
+ # Examples:
27
+ # numpy = ">=1.24"
28
+ # pandas = ">=2.0"
29
+ # scipy = ">=1.10"
30
+ # matplotlib = ">=3.7"
31
+
32
+ # Add a [pypi-dependencies] table only if you need pip-only packages that are
33
+ # not on conda-forge. Prefer conda-forge — and if you add one here, check it
34
+ # doesn't conflict with a conda-pinned version of the same dep.
35
+ #
36
+ # Do NOT add `bocoflow_core` here — the Salpa runtime provides it, not this
37
+ # environment. That's exactly why `pixi run test` can import core.py but not node.py.
38
+
39
+ [tasks]
40
+ # Define tasks that can be run with `pixi run <task>`
41
+ # Use `python -m pytest` so the package root is on sys.path (for `from core import ...`).
42
+ # `-rs` prints skip reasons: the node.py tests skip in this bare env (they need the
43
+ # bocoflow_core runtime) — that's expected, not a failure. Validate node.py on the stack.
44
+ test = "python -m pytest tests/ -v -rs"
@@ -0,0 +1,86 @@
1
+ """Tests for the {{PACKAGE_NAME}} node.
2
+
3
+ Two levels, and the split is load-bearing:
4
+
5
+ * core.py is pure Python, so it imports in a bare pixi env and its tests
6
+ ALWAYS run under `pixi run test`. This is the only part `pixi run test`
7
+ can actually validate.
8
+ * node.py imports bocoflow_core, which the RUNTIME provides and which is
9
+ deliberately NOT a pixi dependency. So node.py cannot import here — its
10
+ tests carry @needs_runtime and skip. Validate node.py by loading the node
11
+ into Salpa and running it on a canvas.
12
+
13
+ Guard the node import PER-TEST with @needs_runtime. Do not use a module-level
14
+ `pytestmark` — that skips the pure core tests below as well, and then
15
+ `pixi run test` exits 0 having validated nothing at all.
16
+ """
17
+
18
+ import json
19
+ import os
20
+
21
+ import pytest
22
+
23
+ from core import process, read_records # pure — always importable here
24
+
25
+ try:
26
+ from node import {{CLASS_NAME}}
27
+ _NODE_OK = True
28
+ except Exception: # node.py imports bocoflow_core (runtime-provided)
29
+ _NODE_OK = False
30
+
31
+ needs_runtime = pytest.mark.skipif(
32
+ not _NODE_OK,
33
+ reason="node.py needs the bocoflow_core runtime; load the node into Salpa to validate it",
34
+ )
35
+
36
+
37
+ # ---- pure core (these RUN in the bare pixi env) ----------------------------
38
+
39
+ def test_read_records_from_text():
40
+ assert read_records("alpha\nbeta\n\n gamma ") == ["alpha", "beta", "gamma"]
41
+
42
+
43
+ def test_read_records_from_file(tmp_path):
44
+ p = tmp_path / "in.txt"
45
+ p.write_text("alpha\nbeta\n", encoding="utf-8")
46
+ assert read_records("", str(p)) == ["alpha", "beta"]
47
+
48
+
49
+ def test_process_counts():
50
+ out = process(["alpha", "beta"])
51
+ assert out["n_in"] == 2
52
+ assert out["n_out"] == 2
53
+
54
+
55
+ # If core.py needs a heavy dep, guard just those tests:
56
+ # pytest.importorskip("rdkit")
57
+
58
+
59
+ # ---- node wiring (needs the bocoflow_core runtime) -------------------------
60
+
61
+ @needs_runtime
62
+ def test_node_instantiation_and_options():
63
+ node = {{CLASS_NAME}}({"node_id": "test"})
64
+ for key in ("text_input", "input_file", "output_dir"):
65
+ assert key in node.OPTIONS
66
+
67
+
68
+ @needs_runtime
69
+ def test_execute_end_to_end(tmp_path):
70
+ class _P: # minimal Parameter stub. Build flow_vars from these rather than
71
+ def __init__(self, v): # from node.OPTIONS — those Parameter objects are
72
+ self._v = v # class attributes shared across every instance,
73
+ # so set_value() on them leaks across tests.
74
+ def get_value(self):
75
+ return self._v
76
+
77
+ node = {{CLASS_NAME}}({"node_id": "test"}) # node_info dict; node_id feeds stream_log(node_id=...)
78
+ flow_vars = {
79
+ "text_input": _P("alpha\nbeta"),
80
+ "input_file": _P(""),
81
+ "output_dir": _P(str(tmp_path)),
82
+ }
83
+ result = json.loads(node.execute(None, flow_vars))
84
+ assert result["success"] is True
85
+ assert result["data"]["n_in"] == 2
86
+ assert os.path.exists(tmp_path / "output.txt")
@@ -0,0 +1,32 @@
1
+ # {{DISPLAY_NAME}} (BoCoFlow package)
2
+
3
+ {{PACKAGE_DESCRIPTION}}
4
+
5
+ A multi-node package: several related nodes that share one pixi environment and
6
+ chain into a pipeline.
7
+
8
+ ## Nodes
9
+
10
+ | Node | Does |
11
+ |------|------|
12
+ | {{NODE1_DISPLAY_NAME}} | {{NODE1_DESCRIPTION}} |
13
+ | {{NODE2_DISPLAY_NAME}} | {{NODE2_DESCRIPTION}} |
14
+
15
+ ## Pipeline
16
+
17
+ `{{NODE1_DISPLAY_NAME}} → {{NODE2_DISPLAY_NAME}}` — node one prepares the input
18
+ and writes it to `result.data`; node two reads that from `predecessor_data`.
19
+
20
+ ## Bundled workflow
21
+
22
+ `workflows/{{PACKAGE_NAME}}-pipeline.json` wires the nodes together. Build it on
23
+ the canvas and export to replace the stub.
24
+
25
+ ## Deployment
26
+
27
+ **Local (PIXI_SUBPROCESS)** — one shared environment (`pixi.toml`) for all nodes.
28
+ List the dependencies and their channels here.
29
+
30
+ ## Status
31
+
32
+ Say plainly what has and has not been verified end-to-end.
@@ -0,0 +1,74 @@
1
+ # multi-node-package — template usage
2
+
3
+ Several related nodes that **share one pixi environment** and chain into a
4
+ pipeline. For a single standalone node, use `individual-node/` instead.
5
+
6
+ > This file is removed by the scaffolder.
7
+
8
+ ## Scaffold it
9
+
10
+ ```bash
11
+ salpa new my-suite -t multi-node-package
12
+ ```
13
+
14
+ Creates `my_suite/` with two starter nodes, `node_one/` and `node_two/`. Rename
15
+ them to real names — **underscored**, because the directory name becomes the
16
+ Python package name.
17
+
18
+ ## The layout, and the three files that must agree
19
+
20
+ ```
21
+ my_suite/
22
+ ├── package.toml # lists the nodes + names the shared environment
23
+ ├── pixi.toml # THE shared env (one, not one per node)
24
+ ├── node_one/ # meta.toml + node.py + core.py + tests/
25
+ ├── node_two/
26
+ └── workflows/ # the bundled demo pipeline (build on canvas, export)
27
+ ```
28
+
29
+ The **shared_environment name** appears in three places and they must match:
30
+ `pixi.toml` `[workspace].name`, `package.toml` `[package.environment]`, and
31
+ **every** node's `meta.toml` `[node].shared_environment`. The runtime reads the
32
+ per-node copy. The scaffolder writes all three; if you add a node by hand, set it
33
+ in that node's meta.toml too.
34
+
35
+ **Conflicting deps? Give that one node its own env.** A member whose dependencies
36
+ can't co-exist with the shared set can opt out: omit `shared_environment` from its
37
+ `meta.toml` and give it its own `pixi.toml` (all its deps + `redis-py`, not
38
+ `bocoflow_core`). The runtime builds that isolated env lazily on first run; the
39
+ rest keep sharing.
40
+
41
+ ## Adding a node
42
+
43
+ 1. Copy an existing node directory (underscored name).
44
+ 2. Add its directory name to `[package.nodes].nodes` in `package.toml`.
45
+ 3. Keep its `meta.toml` `class_name` in sync with the class in its `node.py`, and
46
+ set a globally-unique `[package].name` and the `shared_environment`.
47
+ 4. Add a `test-<node>` task in the root `pixi.toml` and to `test`'s depends-on.
48
+
49
+ ## Stdlib-only? Drop the environment
50
+
51
+ If every node uses only the standard library, delete the root `pixi.toml` AND
52
+ `[package.environment]` in `package.toml`. The nodes then run in-process with no
53
+ environment to build.
54
+
55
+ ## Then
56
+
57
+ ```bash
58
+ cd my_suite
59
+ pixi install
60
+ pixi run test # runs each node's core tests; node tests skip (expected)
61
+ ```
62
+
63
+ Then load the package into Salpa (node library → Import, Local Source) and run the
64
+ pipeline. See `salpa docs multi-node-packages`.
65
+
66
+ ## Checklist
67
+
68
+ - [ ] every node dir in `[package.nodes]`, each with a unique `[package].name`
69
+ - [ ] `class_name` matches the class in each `node.py`
70
+ - [ ] `[package].license` set on every node (missing = the node never registers)
71
+ - [ ] the shared-env name matches across the three files above
72
+ - [ ] `pixi install` solves; `pixi run test` reports **passed** core tests
73
+ - [ ] the bundled workflow is a real canvas export with a `template_info.name`
74
+ - [ ] the pipeline runs end-to-end on a canvas
@@ -0,0 +1,14 @@
1
+ # {{NODE1_DISPLAY_NAME}}
2
+
3
+ {{NODE1_DESCRIPTION}}
4
+
5
+ Part of the **{{DISPLAY_NAME}}** package. Stage 1 of the pipeline.
6
+
7
+ ## Inputs
8
+
9
+ - **Input (one record per line)** — paste directly, or supply an input file.
10
+
11
+ ## Output
12
+
13
+ - `data["prepared"]` — the prepared records (read by {{NODE2_DISPLAY_NAME}})
14
+ - `prepared.txt` in the output directory
@@ -0,0 +1,37 @@
1
+ """Pure-Python core for {{PACKAGE_NAME}} node one.
2
+
3
+ The science lives here; node.py is a thin BoCoFlow wrapper. Keeping them apart
4
+ is what makes this file unit-testable: a bare pixi env can import core.py but
5
+ NOT node.py (node.py needs bocoflow_core, which the runtime provides and which
6
+ is deliberately not a pixi dependency).
7
+
8
+ No BoCoFlow imports here — pure Python. Import heavy dependencies inside the
9
+ function that needs them, not at module scope.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Dict, List
15
+
16
+
17
+ def read_records(text: str = "", file_path: str = "") -> List[str]:
18
+ """Collect input records from a text block (one per line) and/or a file.
19
+
20
+ Note: file reads pass encoding="utf-8" — bare open()/read_text() default to
21
+ cp1252 on Windows and silently corrupt non-ASCII.
22
+ """
23
+ records: List[str] = []
24
+ if text:
25
+ records += [ln.strip() for ln in text.splitlines() if ln.strip()]
26
+ if file_path:
27
+ with open(file_path, encoding="utf-8") as fh:
28
+ records += [ln.strip() for ln in fh if ln.strip()]
29
+ return records
30
+
31
+
32
+ def prepare(records: List[str]) -> Dict:
33
+ """TODO: replace with this node's real first-stage computation."""
34
+ # --- Replace from here ------------------------------------------------
35
+ prepared = [r.strip().lower() for r in records]
36
+ # --- to here ----------------------------------------------------------
37
+ return {"prepared": prepared, "n_prepared": len(prepared)}
@@ -0,0 +1,43 @@
1
+ # Per-node metadata. One of these per node directory listed in package.toml.
2
+ #
3
+ # Note there is NO [dependencies] block: in a shared-environment package the deps
4
+ # live in the package's root pixi.toml, not here.
5
+
6
+ [package]
7
+ # kebab-case, and GLOBALLY unique — the registry keys on it and a collision
8
+ # silently overwrites the other node. Prefixing with the package name keeps it
9
+ # unique. Keep this line comment-free.
10
+ name = "{{PACKAGE_NAME}}-node-one"
11
+ version = "0.1.0"
12
+ description = "{{NODE1_DESCRIPTION}}"
13
+ author = "{{AUTHOR}}"
14
+ license = "MIT" # REQUIRED — a node with no license is skipped at
15
+ # load time and silently never appears in the
16
+ # palette.
17
+
18
+ [node]
19
+ class_name = "{{NODE1_CLASS_NAME}}" # must match the class in node.py exactly
20
+ display_name = "{{NODE1_DISPLAY_NAME}}"
21
+ shared_environment = "{{SHARED_ENV}}" # must match [package.environment] in
22
+ # package.toml. To isolate a node with
23
+ # conflicting deps, omit this AND add a
24
+ # per-node pixi.toml (see package.toml);
25
+ # omit both → server env, imports fail.
26
+ num_in = 0 # a source node — no upstream input
27
+ num_out = 1
28
+ category = "{{CATEGORY}}"
29
+
30
+ hashtags = [{{HASHTAGS}}]
31
+
32
+ [node.metadata]
33
+ icon = "📥"
34
+ tooltip = "{{NODE1_DESCRIPTION}}"
35
+
36
+ [node.features]
37
+ supports_resume = false
38
+ requires_gpu = false
39
+ parallel_capable = false
40
+ long_running = false
41
+
42
+ [documentation]
43
+ readme = "README.md"