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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""{{PACKAGE_NAME}} — node one: {{NODE1_DESCRIPTION}}
|
|
2
|
+
|
|
3
|
+
Stage 1 of the pipeline. Reads input and writes prepared records, which node two
|
|
4
|
+
picks up from `predecessor_data`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from bocoflow_core.node import Node, NodeException, NodeResult
|
|
10
|
+
from bocoflow_core.parameters import (
|
|
11
|
+
FileParameterEdit,
|
|
12
|
+
FolderParameter,
|
|
13
|
+
TextParameter,
|
|
14
|
+
)
|
|
15
|
+
from bocoflow_core.stream_logger import stream_log
|
|
16
|
+
|
|
17
|
+
# Standard 3-stage import. Stage 3 is what lets the SERVER import node.py to read
|
|
18
|
+
# OPTIONS for the UI without this package's dependencies installed.
|
|
19
|
+
try:
|
|
20
|
+
from .core import prepare, read_records # package context (pytest/direct)
|
|
21
|
+
except ImportError:
|
|
22
|
+
try:
|
|
23
|
+
from core import prepare, read_records # node_runner injects node_dir on path
|
|
24
|
+
except ImportError:
|
|
25
|
+
prepare = read_records = None # server env: heavy deps absent
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class {{NODE1_CLASS_NAME}}(Node):
|
|
29
|
+
"""{{NODE1_DESCRIPTION}}"""
|
|
30
|
+
|
|
31
|
+
category = "{{CATEGORY}}"
|
|
32
|
+
tags = [{{HASHTAGS}}]
|
|
33
|
+
|
|
34
|
+
OPTIONS = {
|
|
35
|
+
"text_input": TextParameter(
|
|
36
|
+
"Input (one record per line)", default="",
|
|
37
|
+
docstring="Records to prepare, one per line. Leave empty to use a file.",
|
|
38
|
+
),
|
|
39
|
+
"input_file": FileParameterEdit(
|
|
40
|
+
"Input File (optional)",
|
|
41
|
+
docstring="Optional input file, one record per line.",
|
|
42
|
+
),
|
|
43
|
+
"output_dir": FolderParameter(
|
|
44
|
+
"Output Directory",
|
|
45
|
+
docstring="Directory where prepared records are written.",
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def execute(self, predecessor_data, flow_vars):
|
|
50
|
+
stream_log("Preparing records...", node_id=self.node_id, progress=0)
|
|
51
|
+
try:
|
|
52
|
+
if prepare is None:
|
|
53
|
+
raise NodeException(
|
|
54
|
+
"setup",
|
|
55
|
+
"core.py could not be imported. Check that this package's shared "
|
|
56
|
+
"pixi environment installed its dependencies (`pixi install`).",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
result = NodeResult()
|
|
60
|
+
|
|
61
|
+
text = flow_vars["text_input"].get_value() or ""
|
|
62
|
+
file_val = flow_vars["input_file"].get_value()
|
|
63
|
+
file_path = self.resolve_path(file_val) if file_val else ""
|
|
64
|
+
|
|
65
|
+
records = read_records(text, file_path)
|
|
66
|
+
if not records:
|
|
67
|
+
raise NodeException("input", "No input provided (via text or file).")
|
|
68
|
+
|
|
69
|
+
stream_log(
|
|
70
|
+
f"Preparing {len(records)} record(s)...",
|
|
71
|
+
node_id=self.node_id, progress=40,
|
|
72
|
+
)
|
|
73
|
+
out = prepare(records)
|
|
74
|
+
|
|
75
|
+
out_dir = self.resolve_path(flow_vars["output_dir"].get_value())
|
|
76
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
77
|
+
out_file = os.path.join(out_dir, "prepared.txt")
|
|
78
|
+
with open(out_file, "w", encoding="utf-8") as fh:
|
|
79
|
+
for record in out["prepared"]:
|
|
80
|
+
fh.write(f"{record}\n")
|
|
81
|
+
|
|
82
|
+
# Keys placed in result.data are what node two reads from
|
|
83
|
+
# predecessor_data — this dict IS the contract between the two nodes.
|
|
84
|
+
result.data = {
|
|
85
|
+
**out,
|
|
86
|
+
"prepared_file": self.format_output_path(out_file),
|
|
87
|
+
}
|
|
88
|
+
result.files["output"] = {"prepared_file": result.data["prepared_file"]}
|
|
89
|
+
result.success = True
|
|
90
|
+
result.message = f"Prepared {out['n_prepared']} record(s)"
|
|
91
|
+
stream_log(result.message, node_id=self.node_id, progress=100)
|
|
92
|
+
return result.to_json()
|
|
93
|
+
|
|
94
|
+
except NodeException:
|
|
95
|
+
raise # keep the original stage tag
|
|
96
|
+
except Exception as e:
|
|
97
|
+
raise NodeException("prepare", str(e))
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Tests for {{PACKAGE_NAME}} node one.
|
|
2
|
+
|
|
3
|
+
core.py is pure Python and imports fine here, so its tests ALWAYS run.
|
|
4
|
+
node.py imports bocoflow_core, which the RUNTIME provides and which is not a
|
|
5
|
+
pixi dependency — so its tests carry @needs_runtime and skip. Validate node.py
|
|
6
|
+
by loading the node into Salpa and running it.
|
|
7
|
+
|
|
8
|
+
Guard per-test with @needs_runtime, never with a module-level `pytestmark` —
|
|
9
|
+
a module-level mark skips the core tests too, and `pixi run test` then exits 0
|
|
10
|
+
having validated nothing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from core import prepare, read_records # pure — always importable here
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from node import {{NODE1_CLASS_NAME}}
|
|
22
|
+
_NODE_OK = True
|
|
23
|
+
except Exception: # node.py imports bocoflow_core (runtime-provided)
|
|
24
|
+
_NODE_OK = False
|
|
25
|
+
|
|
26
|
+
needs_runtime = pytest.mark.skipif(
|
|
27
|
+
not _NODE_OK,
|
|
28
|
+
reason="node.py needs the bocoflow_core runtime; load the node into Salpa to validate it",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---- pure core (these RUN in the bare pixi env) ----------------------------
|
|
33
|
+
|
|
34
|
+
def test_read_records_from_text():
|
|
35
|
+
assert read_records("Alpha\nBeta\n\n Gamma ") == ["Alpha", "Beta", "Gamma"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_prepare_normalizes():
|
|
39
|
+
out = prepare(["Alpha", "Beta"])
|
|
40
|
+
assert out["prepared"] == ["alpha", "beta"]
|
|
41
|
+
assert out["n_prepared"] == 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---- node wiring (needs the bocoflow_core runtime) -------------------------
|
|
45
|
+
|
|
46
|
+
@needs_runtime
|
|
47
|
+
def test_execute_writes_prepared_file(tmp_path):
|
|
48
|
+
class _P: # minimal Parameter stub — build flow_vars from these rather than
|
|
49
|
+
def __init__(self, v): # from node.OPTIONS, whose Parameter objects are
|
|
50
|
+
self._v = v # class attributes shared across every instance.
|
|
51
|
+
|
|
52
|
+
def get_value(self):
|
|
53
|
+
return self._v
|
|
54
|
+
|
|
55
|
+
node = {{NODE1_CLASS_NAME}}({"node_id": "test"}) # node_info dict; node_id feeds stream_log(node_id=...)
|
|
56
|
+
flow_vars = {
|
|
57
|
+
"text_input": _P("Alpha\nBeta"),
|
|
58
|
+
"input_file": _P(""),
|
|
59
|
+
"output_dir": _P(str(tmp_path)),
|
|
60
|
+
}
|
|
61
|
+
result = json.loads(node.execute(None, flow_vars))
|
|
62
|
+
assert result["success"] is True
|
|
63
|
+
assert result["data"]["n_prepared"] == 2
|
|
64
|
+
assert os.path.exists(tmp_path / "prepared.txt")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# {{NODE2_DISPLAY_NAME}}
|
|
2
|
+
|
|
3
|
+
{{NODE2_DESCRIPTION}}
|
|
4
|
+
|
|
5
|
+
Part of the **{{DISPLAY_NAME}}** package. Stage 2 of the pipeline — reads what
|
|
6
|
+
{{NODE1_DISPLAY_NAME}} produced from `predecessor_data`.
|
|
7
|
+
|
|
8
|
+
## Inputs
|
|
9
|
+
|
|
10
|
+
- The `prepared` records from an upstream {{NODE1_DISPLAY_NAME}} node.
|
|
11
|
+
|
|
12
|
+
## Output
|
|
13
|
+
|
|
14
|
+
- `data["longest"]` / `data["n_summarized"]`
|
|
15
|
+
- `summary.txt` in the output directory
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Pure-Python core for {{PACKAGE_NAME}} node two.
|
|
2
|
+
|
|
3
|
+
No BoCoFlow imports here — pure Python. See node_one/core.py for why the
|
|
4
|
+
core/node split matters.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Dict, List
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def summarize(records: List[str]) -> Dict:
|
|
13
|
+
"""TODO: replace with this node's real second-stage computation."""
|
|
14
|
+
# --- Replace from here ------------------------------------------------
|
|
15
|
+
longest = max(records, key=len) if records else ""
|
|
16
|
+
# --- to here ----------------------------------------------------------
|
|
17
|
+
return {
|
|
18
|
+
"n_summarized": len(records),
|
|
19
|
+
"longest": longest,
|
|
20
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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. Keep this line comment-free.
|
|
9
|
+
name = "{{PACKAGE_NAME}}-node-two"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = "{{NODE2_DESCRIPTION}}"
|
|
12
|
+
author = "{{AUTHOR}}"
|
|
13
|
+
license = "MIT" # REQUIRED — see node_one/meta.toml
|
|
14
|
+
|
|
15
|
+
[node]
|
|
16
|
+
class_name = "{{NODE2_CLASS_NAME}}" # must match the class in node.py exactly
|
|
17
|
+
display_name = "{{NODE2_DISPLAY_NAME}}"
|
|
18
|
+
shared_environment = "{{SHARED_ENV}}" # must match [package.environment] in package.toml
|
|
19
|
+
num_in = 1 # consumes node_one's output
|
|
20
|
+
num_out = 1
|
|
21
|
+
category = "{{CATEGORY}}"
|
|
22
|
+
|
|
23
|
+
hashtags = [{{HASHTAGS}}]
|
|
24
|
+
|
|
25
|
+
[node.metadata]
|
|
26
|
+
icon = "📤"
|
|
27
|
+
tooltip = "{{NODE2_DESCRIPTION}}"
|
|
28
|
+
|
|
29
|
+
[node.features]
|
|
30
|
+
supports_resume = false
|
|
31
|
+
requires_gpu = false
|
|
32
|
+
parallel_capable = false
|
|
33
|
+
long_running = false
|
|
34
|
+
|
|
35
|
+
[documentation]
|
|
36
|
+
readme = "README.md"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""{{PACKAGE_NAME}} — node two: {{NODE2_DESCRIPTION}}
|
|
2
|
+
|
|
3
|
+
Stage 2 of the pipeline. Reads what node one placed in its `result.data` via
|
|
4
|
+
`predecessor_data`, and demonstrates how nodes in a package chain together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from bocoflow_core.node import Node, NodeException, NodeResult
|
|
10
|
+
from bocoflow_core.parameters import FolderParameter
|
|
11
|
+
from bocoflow_core.stream_logger import stream_log
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from .core import summarize # package context (pytest/direct)
|
|
15
|
+
except ImportError:
|
|
16
|
+
try:
|
|
17
|
+
from core import summarize # node_runner injects node_dir on path
|
|
18
|
+
except ImportError:
|
|
19
|
+
summarize = None # server env: heavy deps absent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _merge_predecessors(predecessor_data):
|
|
23
|
+
"""Flatten upstream node `data` dicts into one carry-forward dict.
|
|
24
|
+
|
|
25
|
+
This is how a node reads what its upstream neighbour produced: whatever the
|
|
26
|
+
previous node put in `result.data` arrives here.
|
|
27
|
+
"""
|
|
28
|
+
merged = {}
|
|
29
|
+
for pred in (predecessor_data or []):
|
|
30
|
+
if not isinstance(pred, dict):
|
|
31
|
+
continue
|
|
32
|
+
scope = pred.get("data") if isinstance(pred.get("data"), dict) else pred
|
|
33
|
+
for k, v in scope.items():
|
|
34
|
+
if k not in ("success", "message", "metadata", "files"):
|
|
35
|
+
merged[k] = v
|
|
36
|
+
return merged
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class {{NODE2_CLASS_NAME}}(Node):
|
|
40
|
+
"""{{NODE2_DESCRIPTION}}"""
|
|
41
|
+
|
|
42
|
+
category = "{{CATEGORY}}"
|
|
43
|
+
tags = [{{HASHTAGS}}]
|
|
44
|
+
|
|
45
|
+
OPTIONS = {
|
|
46
|
+
"output_dir": FolderParameter(
|
|
47
|
+
"Output Directory",
|
|
48
|
+
docstring="Directory where the summary is written.",
|
|
49
|
+
),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def execute(self, predecessor_data, flow_vars):
|
|
53
|
+
stream_log("Summarizing...", node_id=self.node_id, progress=0)
|
|
54
|
+
try:
|
|
55
|
+
if summarize is None:
|
|
56
|
+
raise NodeException(
|
|
57
|
+
"setup",
|
|
58
|
+
"core.py could not be imported. Check that this package's shared "
|
|
59
|
+
"pixi environment installed its dependencies (`pixi install`).",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
carried = _merge_predecessors(predecessor_data)
|
|
63
|
+
result = NodeResult()
|
|
64
|
+
|
|
65
|
+
# node_one put "prepared" in its result.data — that is the contract.
|
|
66
|
+
records = carried.get("prepared")
|
|
67
|
+
if not records:
|
|
68
|
+
raise NodeException(
|
|
69
|
+
"input",
|
|
70
|
+
"No 'prepared' records from the upstream node. Connect this "
|
|
71
|
+
"node's input to {{NODE1_DISPLAY_NAME}} and run that first.",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
stream_log(
|
|
75
|
+
f"Summarizing {len(records)} record(s)...",
|
|
76
|
+
node_id=self.node_id, progress=40,
|
|
77
|
+
)
|
|
78
|
+
out = summarize(records)
|
|
79
|
+
|
|
80
|
+
out_dir = self.resolve_path(flow_vars["output_dir"].get_value())
|
|
81
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
82
|
+
out_file = os.path.join(out_dir, "summary.txt")
|
|
83
|
+
with open(out_file, "w", encoding="utf-8") as fh:
|
|
84
|
+
fh.write(f"records: {out['n_summarized']}\n")
|
|
85
|
+
fh.write(f"longest: {out['longest']}\n")
|
|
86
|
+
|
|
87
|
+
result.data = {
|
|
88
|
+
**carried, # carry upstream keys forward
|
|
89
|
+
**out,
|
|
90
|
+
"summary_file": self.format_output_path(out_file),
|
|
91
|
+
}
|
|
92
|
+
result.files["output"] = {"summary_file": result.data["summary_file"]}
|
|
93
|
+
result.success = True
|
|
94
|
+
result.message = f"Summarized {out['n_summarized']} record(s)"
|
|
95
|
+
stream_log(result.message, node_id=self.node_id, progress=100)
|
|
96
|
+
return result.to_json()
|
|
97
|
+
|
|
98
|
+
except NodeException:
|
|
99
|
+
raise # keep the original stage tag
|
|
100
|
+
except Exception as e:
|
|
101
|
+
raise NodeException("summarize", str(e))
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Tests for {{PACKAGE_NAME}} node two.
|
|
2
|
+
|
|
3
|
+
See node_one/tests/test_node.py for why the node import is guarded per-test with
|
|
4
|
+
@needs_runtime rather than a module-level pytestmark.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from core import summarize # pure — always importable here
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from node import {{NODE2_CLASS_NAME}}
|
|
16
|
+
_NODE_OK = True
|
|
17
|
+
except Exception: # node.py imports bocoflow_core (runtime-provided)
|
|
18
|
+
_NODE_OK = False
|
|
19
|
+
|
|
20
|
+
needs_runtime = pytest.mark.skipif(
|
|
21
|
+
not _NODE_OK,
|
|
22
|
+
reason="node.py needs the bocoflow_core runtime; load the node into Salpa to validate it",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---- pure core (these RUN in the bare pixi env) ----------------------------
|
|
27
|
+
|
|
28
|
+
def test_summarize_counts_and_longest():
|
|
29
|
+
out = summarize(["alpha", "be", "gamma-longest"])
|
|
30
|
+
assert out["n_summarized"] == 3
|
|
31
|
+
assert out["longest"] == "gamma-longest"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_summarize_handles_empty():
|
|
35
|
+
assert summarize([])["n_summarized"] == 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---- node wiring (needs the bocoflow_core runtime) -------------------------
|
|
39
|
+
|
|
40
|
+
@needs_runtime
|
|
41
|
+
def test_execute_reads_predecessor_data(tmp_path):
|
|
42
|
+
class _P:
|
|
43
|
+
def __init__(self, v):
|
|
44
|
+
self._v = v
|
|
45
|
+
|
|
46
|
+
def get_value(self):
|
|
47
|
+
return self._v
|
|
48
|
+
|
|
49
|
+
node = {{NODE2_CLASS_NAME}}({"node_id": "test"})
|
|
50
|
+
# Shaped exactly like what node one returns: the upstream result.data dict.
|
|
51
|
+
predecessor_data = [{"data": {"prepared": ["alpha", "beta"], "n_prepared": 2}}]
|
|
52
|
+
flow_vars = {"output_dir": _P(str(tmp_path))}
|
|
53
|
+
|
|
54
|
+
result = json.loads(node.execute(predecessor_data, flow_vars))
|
|
55
|
+
assert result["success"] is True
|
|
56
|
+
assert result["data"]["n_summarized"] == 2
|
|
57
|
+
assert result["data"]["n_prepared"] == 2 # upstream keys carried forward
|
|
58
|
+
assert os.path.exists(tmp_path / "summary.txt")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@needs_runtime
|
|
62
|
+
def test_execute_without_predecessor_raises():
|
|
63
|
+
class _P:
|
|
64
|
+
def __init__(self, v):
|
|
65
|
+
self._v = v
|
|
66
|
+
|
|
67
|
+
def get_value(self):
|
|
68
|
+
return self._v
|
|
69
|
+
|
|
70
|
+
node = {{NODE2_CLASS_NAME}}({"node_id": "test"})
|
|
71
|
+
with pytest.raises(Exception):
|
|
72
|
+
node.execute(None, {"output_dir": _P("/tmp")})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Multi-node package manifest.
|
|
2
|
+
#
|
|
3
|
+
# The PRESENCE of this file is what makes this a multi-node package — it is how
|
|
4
|
+
# the registry and the installer tell one from a single-node package. Replace
|
|
5
|
+
# every double-brace placeholder below.
|
|
6
|
+
#
|
|
7
|
+
# Guide: https://salpa.app/docs/custom-nodes
|
|
8
|
+
# (also bundled — run `salpa docs multi-node-packages`)
|
|
9
|
+
|
|
10
|
+
[package]
|
|
11
|
+
# kebab-case; the DIRECTORY uses underscores. Keep this line comment-free.
|
|
12
|
+
name = "{{PACKAGE_NAME}}"
|
|
13
|
+
version = "0.1.0"
|
|
14
|
+
description = "{{PACKAGE_DESCRIPTION}}"
|
|
15
|
+
author = "{{AUTHOR}}"
|
|
16
|
+
license = "MIT" # use the real license of the tools you wrap
|
|
17
|
+
|
|
18
|
+
[package.nodes]
|
|
19
|
+
# REQUIRED. These are DIRECTORY names (underscores) — not the kebab-case
|
|
20
|
+
# [package].name inside each node's meta.toml. This list drives both registry
|
|
21
|
+
# discovery and what the installer copies: a node missing here does not ship.
|
|
22
|
+
nodes = [
|
|
23
|
+
"node_one",
|
|
24
|
+
"node_two",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[package.environment]
|
|
28
|
+
# ONE environment shared by every node in this package — the main reason to use a
|
|
29
|
+
# multi-node package rather than N single-node ones. Put ALL nodes' deps here.
|
|
30
|
+
#
|
|
31
|
+
# Normally this name also appears as `shared_environment` in EVERY node's meta.toml,
|
|
32
|
+
# and the runtime reads that per-node copy — keep them in sync (the easiest thing to
|
|
33
|
+
# get wrong here).
|
|
34
|
+
#
|
|
35
|
+
# OPT-OUT — a node whose deps CONFLICT with the shared env can run in its OWN env:
|
|
36
|
+
# omit `shared_environment` in that node's meta.toml AND give it its own pixi.toml
|
|
37
|
+
# (declaring all its deps + `redis-py`, but NOT `bocoflow_core` — the runtime installs
|
|
38
|
+
# that). The runtime builds that env lazily on first run; the rest keep sharing.
|
|
39
|
+
# A node that omits BOTH falls to the server env, where its imports fail.
|
|
40
|
+
#
|
|
41
|
+
# The shared env itself is the pixi.toml beside this file. The installer stages it
|
|
42
|
+
# into shared_environments/<name>/ for you — never create that by hand.
|
|
43
|
+
#
|
|
44
|
+
# Stdlib-only nodes need no environment: delete this section AND the root
|
|
45
|
+
# pixi.toml, and the nodes run IN_PROCESS.
|
|
46
|
+
shared_environment = "{{SHARED_ENV}}"
|
|
47
|
+
|
|
48
|
+
[package.metadata]
|
|
49
|
+
category = "{{CATEGORY}}" # e.g. "Molecular Dynamics", "Metal Docking"
|
|
50
|
+
hashtags = [{{HASHTAGS}}]
|
|
51
|
+
# platform_note = "Requires X, which has no Windows conda package."
|
|
52
|
+
|
|
53
|
+
[package.documentation]
|
|
54
|
+
readme = "README.md"
|
|
55
|
+
|
|
56
|
+
[package.workflows]
|
|
57
|
+
# A bundled demo workflow. To be registrable, the JSON must carry a top-level
|
|
58
|
+
# `template_info` object with a `name` — a plain saved .bcflow is skipped with a
|
|
59
|
+
# "No template_info" warning and nothing is registered.
|
|
60
|
+
#
|
|
61
|
+
# The path below is only used to FIND the file. The published entry is always
|
|
62
|
+
# packages/<pkg>/workflows/<basename>, so the file must live in workflows/.
|
|
63
|
+
examples = ["workflows/{{PACKAGE_NAME}}-pipeline.json"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# THE shared environment for every node in this package.
|
|
2
|
+
#
|
|
3
|
+
# There is exactly one of these per package — by default every member node shares
|
|
4
|
+
# it and ships no pixi.toml of its own. On install, this file is staged to
|
|
5
|
+
# shared_environments/{{SHARED_ENV}}/pixi.toml, which is what the registry
|
|
6
|
+
# resolves for each node that declares `shared_environment` in its meta.toml.
|
|
7
|
+
# This file is the source of truth; the staged copy is generated.
|
|
8
|
+
#
|
|
9
|
+
# Exception — a member whose deps CONFLICT with this shared set can opt out and run
|
|
10
|
+
# in its own env: see the OPT-OUT note in package.toml's [package.environment].
|
|
11
|
+
#
|
|
12
|
+
# Delete this file (and [package.environment] in package.toml) if every node is
|
|
13
|
+
# stdlib-only — they will run IN_PROCESS instead.
|
|
14
|
+
|
|
15
|
+
[workspace]
|
|
16
|
+
# must match [package.environment].shared_environment (keep this line comment-free)
|
|
17
|
+
name = "{{SHARED_ENV}}"
|
|
18
|
+
version = "0.1.0"
|
|
19
|
+
description = "Shared environment for {{PACKAGE_NAME}} nodes"
|
|
20
|
+
channels = ["conda-forge"]
|
|
21
|
+
# Declare ONLY platforms your deps actually build for — pixi solves ALL declared
|
|
22
|
+
# platforms, so one dep with no win-64 build fails the whole solve. Many
|
|
23
|
+
# bioconda / specialized toolchains have no win-64 and sometimes no osx-arm64.
|
|
24
|
+
platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"]
|
|
25
|
+
|
|
26
|
+
[dependencies]
|
|
27
|
+
python = ">=3.10,<3.13"
|
|
28
|
+
|
|
29
|
+
# Redis client for stream_log() — real-time node logs and progress in the UI.
|
|
30
|
+
redis-py = ">=5.0.0"
|
|
31
|
+
|
|
32
|
+
# pytest must be in the DEFAULT env (not a [feature.dev] table) or `pixi run test`
|
|
33
|
+
# reports "pytest: command not found".
|
|
34
|
+
pytest = ">=7"
|
|
35
|
+
|
|
36
|
+
# --- Dependencies for ALL nodes in this package go here, not per-node ---
|
|
37
|
+
# numpy = ">=1.24"
|
|
38
|
+
|
|
39
|
+
[tasks]
|
|
40
|
+
# Run each node's tests from inside its own directory so `from core import ...`
|
|
41
|
+
# resolves — node dirs are not importable packages from the package root.
|
|
42
|
+
# `-rs` prints skip reasons: node.py tests skip in this bare env (they need the
|
|
43
|
+
# bocoflow_core runtime) — expected, not a failure. Add one line per node as it grows.
|
|
44
|
+
test-node-one = { cmd = "python -m pytest tests/ -v -rs", cwd = "node_one" }
|
|
45
|
+
test-node-two = { cmd = "python -m pytest tests/ -v -rs", cwd = "node_two" }
|
|
46
|
+
test = { depends-on = ["test-node-one", "test-node-two"] }
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Bundled demo workflow. This stub carries only the template_info block the registry needs. The real graph/react/settings/flow_vars are produced by BUILDING the pipeline on the canvas and exporting it (Workflow -> Export). Replace this whole file with that export, keeping template_info. A graph authored by hand rarely loads — node_id UUIDs, links, and react layout must be internally consistent.",
|
|
3
|
+
"version": "2.0",
|
|
4
|
+
"template_info": {
|
|
5
|
+
"id": "{{PACKAGE_NAME}}-pipeline",
|
|
6
|
+
"name": "{{DISPLAY_NAME}} Pipeline",
|
|
7
|
+
"description": "{{PACKAGE_DESCRIPTION}}",
|
|
8
|
+
"required_packages": ["{{PACKAGE_NAME}}"],
|
|
9
|
+
"category": "{{CATEGORY}}",
|
|
10
|
+
"difficulty": "beginner",
|
|
11
|
+
"estimated_time": "2 minutes",
|
|
12
|
+
"tags": [{{HASHTAGS}}, "pipeline", "workflow-template"],
|
|
13
|
+
"author": "{{AUTHOR}}"
|
|
14
|
+
},
|
|
15
|
+
"metadata": {},
|
|
16
|
+
"react": { "nodes": [], "edges": [] },
|
|
17
|
+
"settings": {},
|
|
18
|
+
"graph": {
|
|
19
|
+
"directed": true,
|
|
20
|
+
"multigraph": false,
|
|
21
|
+
"graph": {},
|
|
22
|
+
"nodes": [],
|
|
23
|
+
"links": []
|
|
24
|
+
},
|
|
25
|
+
"flow_vars": {}
|
|
26
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: salpa-cli
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Salpa node authoring CLI — scaffold node packages that match the shipped template contract.
|
|
5
|
+
Project-URL: Homepage, https://bocores.com/salpa-cli
|
|
6
|
+
Project-URL: Documentation, https://salpa.app/docs/custom-nodes
|
|
7
|
+
Author: BoundaryComputing
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cli,computational-science,node-authoring,salpa,scaffold,workflow
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: rich>=13
|
|
23
|
+
Requires-Dist: typer>=0.12
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# salpa-cli
|
|
29
|
+
|
|
30
|
+
The Salpa node authoring CLI. Scaffolds node packages that match the shipped
|
|
31
|
+
template contract — the deterministic path to a new node.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python3 -m pip install salpa-cli
|
|
35
|
+
salpa new my-analyzer # interactive
|
|
36
|
+
salpa new my-suite -t multi-node-package --yes
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Installing adds the `salpa` command. If your shell can't find `salpa` afterward,
|
|
40
|
+
your Python scripts directory isn't on your `PATH` —
|
|
41
|
+
installing into a virtual environment is the simplest fix (it puts `salpa` on `PATH`).
|
|
42
|
+
|
|
43
|
+
`salpa new` creates `./<under_scored_name>/` from a bundled template, with every
|
|
44
|
+
placeholder substituted and the directory underscored (the dir name becomes the
|
|
45
|
+
Python package name, so a hyphen would break `from .core import ...`; meta.toml's
|
|
46
|
+
`name` stays kebab-case).
|
|
47
|
+
|
|
48
|
+
## Templates
|
|
49
|
+
|
|
50
|
+
Templates ship inside the wheel and are the single source of truth for the node
|
|
51
|
+
contract, so there is no second copy to drift.
|
|
52
|
+
|
|
53
|
+
| Template | For |
|
|
54
|
+
|----------|-----|
|
|
55
|
+
| `individual-node` | one node with its own pixi env (default) |
|
|
56
|
+
| `multi-node-package` | several related nodes sharing one pixi env, chained via `predecessor_data` |
|
|
57
|
+
|
|
58
|
+
## Options
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
salpa new [NAME]
|
|
62
|
+
-t, --template individual-node | multi-node-package
|
|
63
|
+
-d, --description one-line description
|
|
64
|
+
--author author name
|
|
65
|
+
--category UI category (e.g. "Cheminformatics")
|
|
66
|
+
-o, --output directory to create the package in (default: .)
|
|
67
|
+
-y, --yes non-interactive: accept defaults, no prompts
|
|
68
|
+
--install run `pixi install` in the new package afterwards
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Authoring guide
|
|
72
|
+
|
|
73
|
+
`salpa docs` prints the bundled node-authoring guide. Full documentation:
|
|
74
|
+
https://salpa.app/docs/custom-nodes
|
|
75
|
+
|
|
76
|
+
## Reserved
|
|
77
|
+
|
|
78
|
+
`salpa publish` / `install` / `run` are reserved for later and not implemented
|
|
79
|
+
in v1. v1 is `salpa new` (plus `salpa docs`).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
bocoflow_cli/__init__.py,sha256=SnQMExFig29NtzL6ml6lKCXoQAFxpM_amfVQAlxC1CQ,257
|
|
2
|
+
bocoflow_cli/cli.py,sha256=yDXMurakQOT1yGzCGunZraWyAuknqNEIMe3R942BAZw,9504
|
|
3
|
+
bocoflow_cli/docs.py,sha256=GkBL9uppFFOy_hbYLS06G5CvxzORqskkcdpkmgm-fvo,1607
|
|
4
|
+
bocoflow_cli/scaffold.py,sha256=h2kmpVkmg87jnWKo4TDEtuDdqmwu1DL3O4MRHOtvdb4,7135
|
|
5
|
+
bocoflow_cli/docs/README.md,sha256=jjKTiaI7HaCcKqnWRbd4FVsGvo1kV_IlMYvpSjYp1FQ,804
|
|
6
|
+
bocoflow_cli/docs/multi-node-packages.md,sha256=NR_2dL260IA8yx1elBrXQriWMBXB82IATmmWAhgeT-M,1731
|
|
7
|
+
bocoflow_cli/docs/node-package-structure.md,sha256=uKNK7j_Ug_FxIHk8E1tPD5qFX6XbIp0DaG6MH9tEkzQ,3516
|
|
8
|
+
bocoflow_cli/docs/testing-and-loading-your-node.md,sha256=xsozYebKdI6lsc-LHeetRPHV4uMwE5fIeig2UrYx5kk,1244
|
|
9
|
+
bocoflow_cli/templates/individual-node/README.md,sha256=9VBzXcy5aAW9g8CUf5V6Roba1naTCYdbYnRU0gMNaPo,1130
|
|
10
|
+
bocoflow_cli/templates/individual-node/_TEMPLATE_USAGE.md,sha256=MVYWxoRs2ysdPXwRCptY5S_S71gZWz05salMdkTob60,2978
|
|
11
|
+
bocoflow_cli/templates/individual-node/core.py,sha256=Rwnxw4hRuEsdF5ekvkezIIZYp6UVeMpX6EYoTCIXoK0,1885
|
|
12
|
+
bocoflow_cli/templates/individual-node/meta.toml,sha256=z_iFX6FFc-Mx7Uu1mFCB-u18SHduQ0MfsW_zNfWjzDc,2136
|
|
13
|
+
bocoflow_cli/templates/individual-node/node.py,sha256=3xyAJgOSTeoO0x1h5RHqZ2PUdvxHGJGbK0gzpHkFd64,5441
|
|
14
|
+
bocoflow_cli/templates/individual-node/pixi.toml,sha256=EctsjeA1LIP9WZjD5xntqcP8lDlcfPdeCLMID6jggZc,1687
|
|
15
|
+
bocoflow_cli/templates/individual-node/tests/test_node.py,sha256=1awhi1BXf8X-m8esfjdqJdzKPEOB2oUHEnMM9VaW5eg,2910
|
|
16
|
+
bocoflow_cli/templates/multi-node-package/README.md,sha256=b17FimZIE6X4UddbTDuO37OpzFg5yvyt-qg9_M2XX9c,851
|
|
17
|
+
bocoflow_cli/templates/multi-node-package/_TEMPLATE_USAGE.md,sha256=RCS9OJIIINn0ClnCwipWelJIV112I18u3QcDlTNkpu8,2920
|
|
18
|
+
bocoflow_cli/templates/multi-node-package/package.toml,sha256=OWP2w6n1viBCFFD5su8hv4Ir2Y2gsOSXB02RTlt0IDk,2708
|
|
19
|
+
bocoflow_cli/templates/multi-node-package/pixi.toml,sha256=kYvURCZLrQ1SVT5vPcnOEcIGviafUvqJ4Nvw9WGqwmc,2182
|
|
20
|
+
bocoflow_cli/templates/multi-node-package/node_one/README.md,sha256=blMdEey02NerIsU1A6rfaNHlRxYPN-xymtZ1XQoEk4c,339
|
|
21
|
+
bocoflow_cli/templates/multi-node-package/node_one/core.py,sha256=JlG9paitNlbncVxDvDn5SVUydMhQF8YIrLMSq4GzVcM,1470
|
|
22
|
+
bocoflow_cli/templates/multi-node-package/node_one/meta.toml,sha256=Vb9s2QY9vznEVazPkZEeLtrbnxL07v7OY_bMu1Npym8,1632
|
|
23
|
+
bocoflow_cli/templates/multi-node-package/node_one/node.py,sha256=Wu492KxqKo40zLmZuVVKU2lXu074pe-HvecTOaS1KvQ,3651
|
|
24
|
+
bocoflow_cli/templates/multi-node-package/node_one/tests/test_node.py,sha256=_HGuABb4x3POwS4DQb_uFhjghhPsJmHCc18xLw5hXuo,2174
|
|
25
|
+
bocoflow_cli/templates/multi-node-package/node_two/README.md,sha256=KoUcgmyPD7BOKubiheFPt3p9SBF6d7Tfk3I-dJlP-9U,367
|
|
26
|
+
bocoflow_cli/templates/multi-node-package/node_two/core.py,sha256=LYERqJKVFzJ7bbCBcLdiVYFbR5p-RR9b4mtG8-m8OAU,634
|
|
27
|
+
bocoflow_cli/templates/multi-node-package/node_two/meta.toml,sha256=yQxjlSxSWJJqXHaXmZT-zbiDuE5Z65_CvmvtWOqoTDA,1120
|
|
28
|
+
bocoflow_cli/templates/multi-node-package/node_two/node.py,sha256=WtDfMYTjaAF0Go-4vhjMevFWJYnASkiK6ER8tSAfc4w,3789
|
|
29
|
+
bocoflow_cli/templates/multi-node-package/node_two/tests/test_node.py,sha256=7UdAKcHq0YVmy_iazaahDJB72pYZbi5_IWnqNJrQGdE,2117
|
|
30
|
+
bocoflow_cli/templates/multi-node-package/workflows/{{PACKAGE_NAME}}-pipeline.json,sha256=sJMD2EcV-I-sCSddpBNpAY3kth69cqidkJGBVX25k2E,1033
|
|
31
|
+
salpa_cli-0.1.2.dist-info/METADATA,sha256=cHkq2KTTFsCt_N6AQjFuUe2PVQgBB4AHqzDUBYK-tPQ,2916
|
|
32
|
+
salpa_cli-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
33
|
+
salpa_cli-0.1.2.dist-info/entry_points.txt,sha256=WTdnthEebNs7iECOOw6UFtaQw7-oBkpdzJGQRZ_NNSs,79
|
|
34
|
+
salpa_cli-0.1.2.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
35
|
+
salpa_cli-0.1.2.dist-info/RECORD,,
|