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,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"
|