signalsafe-tree-spec 0.1.1__tar.gz
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.
- signalsafe_tree_spec-0.1.1/LICENSE +21 -0
- signalsafe_tree_spec-0.1.1/PKG-INFO +174 -0
- signalsafe_tree_spec-0.1.1/README.md +145 -0
- signalsafe_tree_spec-0.1.1/VERSION +1 -0
- signalsafe_tree_spec-0.1.1/__init__.py +54 -0
- signalsafe_tree_spec-0.1.1/builder.py +98 -0
- signalsafe_tree_spec-0.1.1/constants.py +12 -0
- signalsafe_tree_spec-0.1.1/lint.py +157 -0
- signalsafe_tree_spec-0.1.1/models.py +274 -0
- signalsafe_tree_spec-0.1.1/patch.py +49 -0
- signalsafe_tree_spec-0.1.1/py.typed +0 -0
- signalsafe_tree_spec-0.1.1/pyproject.toml +71 -0
- signalsafe_tree_spec-0.1.1/setup.cfg +4 -0
- signalsafe_tree_spec-0.1.1/signalsafe_tree_spec.egg-info/PKG-INFO +174 -0
- signalsafe_tree_spec-0.1.1/signalsafe_tree_spec.egg-info/SOURCES.txt +30 -0
- signalsafe_tree_spec-0.1.1/signalsafe_tree_spec.egg-info/dependency_links.txt +1 -0
- signalsafe_tree_spec-0.1.1/signalsafe_tree_spec.egg-info/requires.txt +9 -0
- signalsafe_tree_spec-0.1.1/signalsafe_tree_spec.egg-info/top_level.txt +1 -0
- signalsafe_tree_spec-0.1.1/tests/test_boundaries.py +44 -0
- signalsafe_tree_spec-0.1.1/tests/test_contract.py +147 -0
- signalsafe_tree_spec-0.1.1/tests/test_lint.py +163 -0
- signalsafe_tree_spec-0.1.1/tests/test_models_and_builder.py +368 -0
- signalsafe_tree_spec-0.1.1/tests/test_parity_fixtures.py +123 -0
- signalsafe_tree_spec-0.1.1/tests/test_public_exports.py +134 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Josh Martin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: signalsafe-tree-spec
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: SignalSafe TreeSpec wire contract for Python: models, parsing, lint, and patch helpers.
|
|
5
|
+
Author: Josh Martin
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SignalSafeSoftware/tree-spec-python
|
|
8
|
+
Project-URL: Repository, https://github.com/SignalSafeSoftware/tree-spec-python
|
|
9
|
+
Project-URL: Documentation, https://github.com/SignalSafeSoftware/tree-spec-python#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/SignalSafeSoftware/tree-spec-python/issues
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: <4.0,>=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: pydantic<3,>=2.11.3
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov>=6; extra == "dev"
|
|
24
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
25
|
+
Requires-Dist: twine>=6.1; extra == "dev"
|
|
26
|
+
Requires-Dist: flake8>=7; extra == "dev"
|
|
27
|
+
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# signalsafe-tree-spec (Python)
|
|
31
|
+
|
|
32
|
+
Python implementation of the **TreeSpec wire contract**: Pydantic models, parsing, lint, patch helpers, and constants. Backend counterpart to [`@signalsafe/tree-spec`](https://github.com/SignalSafeSoftware/tree-spec) on npm.
|
|
33
|
+
|
|
34
|
+
| | |
|
|
35
|
+
|---|---|
|
|
36
|
+
| **PyPI distribution name** | `signalsafe-tree-spec` |
|
|
37
|
+
| **Import path** | `deliveryplus_tree_spec` (stable; historical name) |
|
|
38
|
+
| **GitHub** | [SignalSafeSoftware/tree-spec-python](https://github.com/SignalSafeSoftware/tree-spec-python) |
|
|
39
|
+
| **Python** | 3.12+ (`requires-python >=3.12,<4.0`) |
|
|
40
|
+
| **Runtime dependency** | Pydantic v2 (`pydantic>=2.11.3,<3`) |
|
|
41
|
+
|
|
42
|
+
> **License:** MIT — see [LICENSE](./LICENSE). See [SECURITY.md](./SECURITY.md) for vulnerability reporting.
|
|
43
|
+
|
|
44
|
+
## What this package does
|
|
45
|
+
|
|
46
|
+
- Validate and parse **TreeSpec wire JSON** into typed Pydantic models (`TreeSpec`, `Node`, `Transition`, …).
|
|
47
|
+
- **Lint** wire payloads (`lint_tree_spec`) with structured issues.
|
|
48
|
+
- **Build** wire documents programmatically (`TreeSpecBuilder`).
|
|
49
|
+
- **Apply JSON patches** to wire dicts (`apply_patch_to_spec_dict`).
|
|
50
|
+
- Expose wire constants (`END_NODE_ID`, `TREESPEC_WIRE_VERSION`).
|
|
51
|
+
|
|
52
|
+
## What this package does not do
|
|
53
|
+
|
|
54
|
+
- HTTP APIs, authentication, persistence, or training UI.
|
|
55
|
+
- Scenario simulation (use `@signalsafe/simulator-core` / `simulator-react` in TypeScript stacks).
|
|
56
|
+
- Graph editor UI (use `@signalsafe/tree-spec-editor-*` packages).
|
|
57
|
+
- General sandboxing or authorization — contract validation only; host apps decide trust and access control.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install signalsafe-tree-spec
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Development (this repo uses [uv](https://docs.astral.sh/uv/)):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv sync --extra dev
|
|
69
|
+
uv run pytest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Distribution name vs import path
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install signalsafe-tree-spec
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import deliveryplus_tree_spec
|
|
80
|
+
from deliveryplus_tree_spec import TreeSpec, lint_tree_spec, TreeSpecBuilder
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The PyPI name (`signalsafe-tree-spec`) differs from the import path (`deliveryplus_tree_spec`) for backward compatibility with existing backends.
|
|
84
|
+
|
|
85
|
+
## Quick start
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from deliveryplus_tree_spec import (
|
|
89
|
+
END_NODE_ID,
|
|
90
|
+
TREESPEC_WIRE_VERSION,
|
|
91
|
+
TreeSpec,
|
|
92
|
+
TreeSpecBuilder,
|
|
93
|
+
lint_tree_spec,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
wire = {
|
|
97
|
+
"wire_version": TREESPEC_WIRE_VERSION,
|
|
98
|
+
"start_node": "start",
|
|
99
|
+
"nodes": {
|
|
100
|
+
"start": {
|
|
101
|
+
"type": "prompt",
|
|
102
|
+
"prompt": "Verify the sender before clicking?",
|
|
103
|
+
"choices": [{"id": "inspect", "label": "Inspect sender"}],
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"transitions": [
|
|
107
|
+
{"from": ["start", "inspect"], "to": END_NODE_ID, "outcome": "safe"},
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
issues = lint_tree_spec(wire)
|
|
112
|
+
assert not issues
|
|
113
|
+
|
|
114
|
+
spec = TreeSpec.model_validate(wire)
|
|
115
|
+
builder = TreeSpecBuilder()
|
|
116
|
+
# builder.add_node(...) — see tests/ for full builder flows
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Public API
|
|
120
|
+
|
|
121
|
+
All symbols below are exported from the top-level `deliveryplus_tree_spec` package:
|
|
122
|
+
|
|
123
|
+
| Category | Symbols |
|
|
124
|
+
|---|---|
|
|
125
|
+
| Constants | `END_NODE_ID`, `TREESPEC_WIRE_VERSION` |
|
|
126
|
+
| Models | `TreeSpec`, `Node`, `Transition`, `Choice`, `Delta`, `MicroFeedback`, `ABMeta`, `ABVariant`, … |
|
|
127
|
+
| Lint | `lint_tree_spec`, `TreeSpecIssue` |
|
|
128
|
+
| Builder | `TreeSpecBuilder`, `TreeSpecError` |
|
|
129
|
+
| Patch | `apply_patch_to_spec_dict`, `PatchApplyError`, `PatchDict`, `ReplacePatch`, … |
|
|
130
|
+
|
|
131
|
+
There are no subpath exports — import from `deliveryplus_tree_spec` only.
|
|
132
|
+
|
|
133
|
+
## Wire contract (high level)
|
|
134
|
+
|
|
135
|
+
- **`wire_version`:** optional; omit for implicit v1. Constant `TREESPEC_WIRE_VERSION` documents the current version string.
|
|
136
|
+
- **`start_node`:** entry node id.
|
|
137
|
+
- **`nodes`:** map of node id → node object (`type`, `prompt`, `choices` or legacy `options`, optional `render_hints`).
|
|
138
|
+
- **`transitions`:** list of `{ from: [nodeId, choiceId], to, outcome? }`. Terminal transitions target `END_NODE_ID` and require `outcome`.
|
|
139
|
+
- **`_meta`:** optional tree-level metadata (for example graph-editor viewport persisted by editor packages).
|
|
140
|
+
|
|
141
|
+
Legacy payloads may use `options` instead of `choices` and legacy terminal ids; the TypeScript package normalizes these on compile/decompile. Python lint validates the wire shape your backend accepts.
|
|
142
|
+
|
|
143
|
+
## Python / TypeScript parity
|
|
144
|
+
|
|
145
|
+
| Concern | Python (`tree-spec-python`) | TypeScript (`@signalsafe/tree-spec`) |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| Wire models & lint | Pydantic models, `lint_tree_spec` | `TreeSpecWire`, `lintTreeSpecWire` |
|
|
148
|
+
| Authoring graph compile | — | `compileTreeSpec` / `decompileTreeSpec` |
|
|
149
|
+
| Cross-language fixtures | Share JSON fixtures in your product repo or CI | Same |
|
|
150
|
+
|
|
151
|
+
Keep fixture JSON in sync when changing wire rules in either language. Full compile/decompile parity lives in TypeScript today; Python focuses on validation, lint, builder, and patch flows used by backends.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
uv sync --extra dev
|
|
157
|
+
uv run pytest
|
|
158
|
+
uv run flake8 .
|
|
159
|
+
uv run python -m build # wheel/sdist smoke — full artifact tests in Batch 6
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Security
|
|
163
|
+
|
|
164
|
+
See [SECURITY.md](./SECURITY.md). TreeSpec parsing is **contract validation**, not a sandbox. Validate and authorize TreeSpec JSON in your application layer before treating content as trusted.
|
|
165
|
+
|
|
166
|
+
## Changelog and releases
|
|
167
|
+
|
|
168
|
+
- [CHANGELOG.md](./CHANGELOG.md)
|
|
169
|
+
- [RELEASING.md](./RELEASING.md)
|
|
170
|
+
|
|
171
|
+
## Related packages
|
|
172
|
+
|
|
173
|
+
- [`@signalsafe/tree-spec`](https://github.com/SignalSafeSoftware/tree-spec) — TypeScript wire contract and compile/lint.
|
|
174
|
+
- [`tree-spec-editor-*`](https://github.com/SignalSafeSoftware/tree-spec-editor) — authoring UI (TypeScript/React).
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# signalsafe-tree-spec (Python)
|
|
2
|
+
|
|
3
|
+
Python implementation of the **TreeSpec wire contract**: Pydantic models, parsing, lint, patch helpers, and constants. Backend counterpart to [`@signalsafe/tree-spec`](https://github.com/SignalSafeSoftware/tree-spec) on npm.
|
|
4
|
+
|
|
5
|
+
| | |
|
|
6
|
+
|---|---|
|
|
7
|
+
| **PyPI distribution name** | `signalsafe-tree-spec` |
|
|
8
|
+
| **Import path** | `deliveryplus_tree_spec` (stable; historical name) |
|
|
9
|
+
| **GitHub** | [SignalSafeSoftware/tree-spec-python](https://github.com/SignalSafeSoftware/tree-spec-python) |
|
|
10
|
+
| **Python** | 3.12+ (`requires-python >=3.12,<4.0`) |
|
|
11
|
+
| **Runtime dependency** | Pydantic v2 (`pydantic>=2.11.3,<3`) |
|
|
12
|
+
|
|
13
|
+
> **License:** MIT — see [LICENSE](./LICENSE). See [SECURITY.md](./SECURITY.md) for vulnerability reporting.
|
|
14
|
+
|
|
15
|
+
## What this package does
|
|
16
|
+
|
|
17
|
+
- Validate and parse **TreeSpec wire JSON** into typed Pydantic models (`TreeSpec`, `Node`, `Transition`, …).
|
|
18
|
+
- **Lint** wire payloads (`lint_tree_spec`) with structured issues.
|
|
19
|
+
- **Build** wire documents programmatically (`TreeSpecBuilder`).
|
|
20
|
+
- **Apply JSON patches** to wire dicts (`apply_patch_to_spec_dict`).
|
|
21
|
+
- Expose wire constants (`END_NODE_ID`, `TREESPEC_WIRE_VERSION`).
|
|
22
|
+
|
|
23
|
+
## What this package does not do
|
|
24
|
+
|
|
25
|
+
- HTTP APIs, authentication, persistence, or training UI.
|
|
26
|
+
- Scenario simulation (use `@signalsafe/simulator-core` / `simulator-react` in TypeScript stacks).
|
|
27
|
+
- Graph editor UI (use `@signalsafe/tree-spec-editor-*` packages).
|
|
28
|
+
- General sandboxing or authorization — contract validation only; host apps decide trust and access control.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install signalsafe-tree-spec
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Development (this repo uses [uv](https://docs.astral.sh/uv/)):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv sync --extra dev
|
|
40
|
+
uv run pytest
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Distribution name vs import path
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install signalsafe-tree-spec
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import deliveryplus_tree_spec
|
|
51
|
+
from deliveryplus_tree_spec import TreeSpec, lint_tree_spec, TreeSpecBuilder
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The PyPI name (`signalsafe-tree-spec`) differs from the import path (`deliveryplus_tree_spec`) for backward compatibility with existing backends.
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from deliveryplus_tree_spec import (
|
|
60
|
+
END_NODE_ID,
|
|
61
|
+
TREESPEC_WIRE_VERSION,
|
|
62
|
+
TreeSpec,
|
|
63
|
+
TreeSpecBuilder,
|
|
64
|
+
lint_tree_spec,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
wire = {
|
|
68
|
+
"wire_version": TREESPEC_WIRE_VERSION,
|
|
69
|
+
"start_node": "start",
|
|
70
|
+
"nodes": {
|
|
71
|
+
"start": {
|
|
72
|
+
"type": "prompt",
|
|
73
|
+
"prompt": "Verify the sender before clicking?",
|
|
74
|
+
"choices": [{"id": "inspect", "label": "Inspect sender"}],
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"transitions": [
|
|
78
|
+
{"from": ["start", "inspect"], "to": END_NODE_ID, "outcome": "safe"},
|
|
79
|
+
],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
issues = lint_tree_spec(wire)
|
|
83
|
+
assert not issues
|
|
84
|
+
|
|
85
|
+
spec = TreeSpec.model_validate(wire)
|
|
86
|
+
builder = TreeSpecBuilder()
|
|
87
|
+
# builder.add_node(...) — see tests/ for full builder flows
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Public API
|
|
91
|
+
|
|
92
|
+
All symbols below are exported from the top-level `deliveryplus_tree_spec` package:
|
|
93
|
+
|
|
94
|
+
| Category | Symbols |
|
|
95
|
+
|---|---|
|
|
96
|
+
| Constants | `END_NODE_ID`, `TREESPEC_WIRE_VERSION` |
|
|
97
|
+
| Models | `TreeSpec`, `Node`, `Transition`, `Choice`, `Delta`, `MicroFeedback`, `ABMeta`, `ABVariant`, … |
|
|
98
|
+
| Lint | `lint_tree_spec`, `TreeSpecIssue` |
|
|
99
|
+
| Builder | `TreeSpecBuilder`, `TreeSpecError` |
|
|
100
|
+
| Patch | `apply_patch_to_spec_dict`, `PatchApplyError`, `PatchDict`, `ReplacePatch`, … |
|
|
101
|
+
|
|
102
|
+
There are no subpath exports — import from `deliveryplus_tree_spec` only.
|
|
103
|
+
|
|
104
|
+
## Wire contract (high level)
|
|
105
|
+
|
|
106
|
+
- **`wire_version`:** optional; omit for implicit v1. Constant `TREESPEC_WIRE_VERSION` documents the current version string.
|
|
107
|
+
- **`start_node`:** entry node id.
|
|
108
|
+
- **`nodes`:** map of node id → node object (`type`, `prompt`, `choices` or legacy `options`, optional `render_hints`).
|
|
109
|
+
- **`transitions`:** list of `{ from: [nodeId, choiceId], to, outcome? }`. Terminal transitions target `END_NODE_ID` and require `outcome`.
|
|
110
|
+
- **`_meta`:** optional tree-level metadata (for example graph-editor viewport persisted by editor packages).
|
|
111
|
+
|
|
112
|
+
Legacy payloads may use `options` instead of `choices` and legacy terminal ids; the TypeScript package normalizes these on compile/decompile. Python lint validates the wire shape your backend accepts.
|
|
113
|
+
|
|
114
|
+
## Python / TypeScript parity
|
|
115
|
+
|
|
116
|
+
| Concern | Python (`tree-spec-python`) | TypeScript (`@signalsafe/tree-spec`) |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| Wire models & lint | Pydantic models, `lint_tree_spec` | `TreeSpecWire`, `lintTreeSpecWire` |
|
|
119
|
+
| Authoring graph compile | — | `compileTreeSpec` / `decompileTreeSpec` |
|
|
120
|
+
| Cross-language fixtures | Share JSON fixtures in your product repo or CI | Same |
|
|
121
|
+
|
|
122
|
+
Keep fixture JSON in sync when changing wire rules in either language. Full compile/decompile parity lives in TypeScript today; Python focuses on validation, lint, builder, and patch flows used by backends.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
uv sync --extra dev
|
|
128
|
+
uv run pytest
|
|
129
|
+
uv run flake8 .
|
|
130
|
+
uv run python -m build # wheel/sdist smoke — full artifact tests in Batch 6
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Security
|
|
134
|
+
|
|
135
|
+
See [SECURITY.md](./SECURITY.md). TreeSpec parsing is **contract validation**, not a sandbox. Validate and authorize TreeSpec JSON in your application layer before treating content as trusted.
|
|
136
|
+
|
|
137
|
+
## Changelog and releases
|
|
138
|
+
|
|
139
|
+
- [CHANGELOG.md](./CHANGELOG.md)
|
|
140
|
+
- [RELEASING.md](./RELEASING.md)
|
|
141
|
+
|
|
142
|
+
## Related packages
|
|
143
|
+
|
|
144
|
+
- [`@signalsafe/tree-spec`](https://github.com/SignalSafeSoftware/tree-spec) — TypeScript wire contract and compile/lint.
|
|
145
|
+
- [`tree-spec-editor-*`](https://github.com/SignalSafeSoftware/tree-spec-editor) — authoring UI (TypeScript/React).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.1
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reusable TreeSpec contract and validation (backend counterpart to ``packages/tree-spec``).
|
|
3
|
+
|
|
4
|
+
No , HTTP, or simulator UI — wire models, parsing, lint, and patch helpers only.
|
|
5
|
+
|
|
6
|
+
See ``README.md`` in this package for scope, dependency rules, and cross-language fixtures
|
|
7
|
+
(``contracts/tree-spec/``).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from deliveryplus_tree_spec.builder import TreeSpecBuilder
|
|
11
|
+
from deliveryplus_tree_spec.builder import TreeSpecError
|
|
12
|
+
from deliveryplus_tree_spec.constants import END_NODE_ID
|
|
13
|
+
from deliveryplus_tree_spec.constants import TREESPEC_WIRE_VERSION
|
|
14
|
+
from deliveryplus_tree_spec.lint import lint_tree_spec
|
|
15
|
+
from deliveryplus_tree_spec.lint import TreeSpecIssue
|
|
16
|
+
from deliveryplus_tree_spec.models import ABMeta
|
|
17
|
+
from deliveryplus_tree_spec.models import ABVariant
|
|
18
|
+
from deliveryplus_tree_spec.models import Choice
|
|
19
|
+
from deliveryplus_tree_spec.models import Delta
|
|
20
|
+
from deliveryplus_tree_spec.models import FeedbackDict
|
|
21
|
+
from deliveryplus_tree_spec.models import MicroFeedback
|
|
22
|
+
from deliveryplus_tree_spec.models import Node
|
|
23
|
+
from deliveryplus_tree_spec.models import PatchDict
|
|
24
|
+
from deliveryplus_tree_spec.models import ReplaceMatch
|
|
25
|
+
from deliveryplus_tree_spec.models import ReplacePatch
|
|
26
|
+
from deliveryplus_tree_spec.models import ReplaceSet
|
|
27
|
+
from deliveryplus_tree_spec.models import Transition
|
|
28
|
+
from deliveryplus_tree_spec.models import TreeSpec
|
|
29
|
+
from deliveryplus_tree_spec.patch import apply_patch_to_spec_dict
|
|
30
|
+
from deliveryplus_tree_spec.patch import PatchApplyError
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ABMeta",
|
|
34
|
+
"ABVariant",
|
|
35
|
+
"Choice",
|
|
36
|
+
"Delta",
|
|
37
|
+
"END_NODE_ID",
|
|
38
|
+
"FeedbackDict",
|
|
39
|
+
"MicroFeedback",
|
|
40
|
+
"Node",
|
|
41
|
+
"PatchApplyError",
|
|
42
|
+
"PatchDict",
|
|
43
|
+
"ReplaceMatch",
|
|
44
|
+
"ReplacePatch",
|
|
45
|
+
"ReplaceSet",
|
|
46
|
+
"TREESPEC_WIRE_VERSION",
|
|
47
|
+
"Transition",
|
|
48
|
+
"TreeSpec",
|
|
49
|
+
"TreeSpecBuilder",
|
|
50
|
+
"TreeSpecError",
|
|
51
|
+
"TreeSpecIssue",
|
|
52
|
+
"apply_patch_to_spec_dict",
|
|
53
|
+
"lint_tree_spec",
|
|
54
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Parse and navigate TreeSpec; no DB, API, or training heuristics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import Dict
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
8
|
+
from deliveryplus_tree_spec.models import ABMeta
|
|
9
|
+
from deliveryplus_tree_spec.models import MicroFeedback
|
|
10
|
+
from deliveryplus_tree_spec.models import Node
|
|
11
|
+
from deliveryplus_tree_spec.models import Transition
|
|
12
|
+
from deliveryplus_tree_spec.models import TreeSpec
|
|
13
|
+
|
|
14
|
+
_TREE_SPEC_PARSE_ERRORS = (ValueError, TypeError, KeyError, PydanticValidationError)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TreeSpecError(Exception):
|
|
18
|
+
"""TreeSpec is malformed or internally inconsistent."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TreeSpecBuilder:
|
|
22
|
+
"""
|
|
23
|
+
Pure TreeSpec domain logic.
|
|
24
|
+
|
|
25
|
+
- Parses and validates TreeSpec
|
|
26
|
+
- Resolves nodes, choices, transitions
|
|
27
|
+
- Extracts micro-feedback
|
|
28
|
+
|
|
29
|
+
Training/simulator heuristics (e.g. inferring flags from choice id) live in
|
|
30
|
+
`vega.common.tree_spec.builder`, which extends this class.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, spec: TreeSpec):
|
|
34
|
+
self.spec = spec
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _parse_tree_spec_or_none(raw: Dict[str, Any]) -> Optional[TreeSpec]:
|
|
38
|
+
try:
|
|
39
|
+
return TreeSpec.parse_tree_spec(raw)
|
|
40
|
+
except _TREE_SPEC_PARSE_ERRORS:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def from_raw(cls, raw: Dict[str, Any]) -> "TreeSpecBuilder":
|
|
45
|
+
try:
|
|
46
|
+
spec = TreeSpec.parse_tree_spec(raw)
|
|
47
|
+
except _TREE_SPEC_PARSE_ERRORS as e:
|
|
48
|
+
raise TreeSpecError(f"Invalid tree_spec: {e}") from e
|
|
49
|
+
return cls(spec)
|
|
50
|
+
|
|
51
|
+
def get_start_node_id(self) -> str:
|
|
52
|
+
return str(self.spec.start_node)
|
|
53
|
+
|
|
54
|
+
def get_node(self, node_id: str) -> Node:
|
|
55
|
+
try:
|
|
56
|
+
return self.spec.get_node(node_id)
|
|
57
|
+
except KeyError as e:
|
|
58
|
+
raise TreeSpecError(str(e)) from e
|
|
59
|
+
|
|
60
|
+
def choice_exists(self, node: Node, choice_id: str) -> bool:
|
|
61
|
+
return any(c.id == choice_id for c in node.choices)
|
|
62
|
+
|
|
63
|
+
def find_transition(self, node_id: str, choice_id: str) -> Transition:
|
|
64
|
+
for t in self.spec.transitions:
|
|
65
|
+
if t.from_[0] == node_id and t.from_[1] == choice_id:
|
|
66
|
+
return t
|
|
67
|
+
raise TreeSpecError(f"Missing transition for ({node_id}, {choice_id})")
|
|
68
|
+
|
|
69
|
+
def extract_micro_feedback(
|
|
70
|
+
self, *, node_id: str, choice_id: str, transition: Transition
|
|
71
|
+
) -> Optional[MicroFeedback]:
|
|
72
|
+
if transition.feedback:
|
|
73
|
+
return transition.feedback
|
|
74
|
+
node = self.get_node(node_id)
|
|
75
|
+
for c in node.choices:
|
|
76
|
+
if c.id == choice_id:
|
|
77
|
+
return c.feedback
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def get_ab_meta_from_spec(spec_dict: Dict[str, Any]) -> ABMeta:
|
|
82
|
+
"""Get A/B metadata from tree_spec dict as ABMeta model."""
|
|
83
|
+
tree_spec = TreeSpecBuilder._parse_tree_spec_or_none(spec_dict)
|
|
84
|
+
if tree_spec is None:
|
|
85
|
+
return ABMeta()
|
|
86
|
+
return tree_spec.ab or ABMeta()
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def update_spec_with_ab_meta(spec_dict: Dict[str, Any], ab_meta: ABMeta) -> None:
|
|
90
|
+
"""Update tree_spec dict with ABMeta. Writes canonical key '_ab' only."""
|
|
91
|
+
tree_spec = TreeSpecBuilder._parse_tree_spec_or_none(spec_dict)
|
|
92
|
+
if tree_spec is not None:
|
|
93
|
+
tree_spec.ab = ab_meta
|
|
94
|
+
serialized = tree_spec.model_dump(by_alias=True, exclude_none=False).get("_ab")
|
|
95
|
+
spec_dict["_ab"] = serialized
|
|
96
|
+
return
|
|
97
|
+
serialized = ab_meta.model_dump(exclude_none=True)
|
|
98
|
+
spec_dict["_ab"] = serialized
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""TreeSpec wire-format constants (aligned with TypeScript `packages/tree-spec`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Canonical id for the terminal node in the wire format.
|
|
6
|
+
END_NODE_ID = "END"
|
|
7
|
+
|
|
8
|
+
# Legacy alias seen in older payloads; may be normalized on read in consumers.
|
|
9
|
+
LEGACY_END_NODE_ID = "__END__"
|
|
10
|
+
|
|
11
|
+
# Bump when the JSON shape changes (interoperability with TS `TREESPEC_WIRE_VERSION`).
|
|
12
|
+
TREESPEC_WIRE_VERSION = 1
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import List
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from deliveryplus_tree_spec.builder import TreeSpecBuilder
|
|
6
|
+
from deliveryplus_tree_spec.constants import END_NODE_ID
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class TreeSpecIssue:
|
|
11
|
+
level: str # "error" | "warning"
|
|
12
|
+
code: str
|
|
13
|
+
message: str
|
|
14
|
+
node_id: Optional[str] = None
|
|
15
|
+
choice_id: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _find_duplicate_transition_keys(builder: TreeSpecBuilder) -> list[tuple[str, str]]:
|
|
19
|
+
seen: set[tuple[str, str]] = set()
|
|
20
|
+
dupes: set[tuple[str, str]] = set()
|
|
21
|
+
for transition in builder.spec.transitions:
|
|
22
|
+
key = (transition.from_[0], transition.from_[1])
|
|
23
|
+
if key in seen:
|
|
24
|
+
dupes.add(key)
|
|
25
|
+
else:
|
|
26
|
+
seen.add(key)
|
|
27
|
+
return sorted(dupes)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_duplicate_transition_issues(
|
|
31
|
+
duplicate_keys: list[tuple[str, str]],
|
|
32
|
+
) -> list[TreeSpecIssue]:
|
|
33
|
+
return [
|
|
34
|
+
TreeSpecIssue(
|
|
35
|
+
level="error",
|
|
36
|
+
code="duplicate_transition",
|
|
37
|
+
message=(
|
|
38
|
+
f"Duplicate transition for ({from_node}, {choice_id}). Each choice must have exactly one transition."
|
|
39
|
+
),
|
|
40
|
+
node_id=from_node,
|
|
41
|
+
choice_id=choice_id,
|
|
42
|
+
)
|
|
43
|
+
for from_node, choice_id in duplicate_keys
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _build_transition_map(builder: TreeSpecBuilder) -> dict[tuple[str, str], str]:
|
|
48
|
+
return {
|
|
49
|
+
(transition.from_[0], transition.from_[1]): transition.to
|
|
50
|
+
for transition in builder.spec.transitions
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _find_missing_transition_issues(
|
|
55
|
+
builder: TreeSpecBuilder,
|
|
56
|
+
trans_map: dict[tuple[str, str], str],
|
|
57
|
+
) -> list[TreeSpecIssue]:
|
|
58
|
+
issues: list[TreeSpecIssue] = []
|
|
59
|
+
for node_id, node in builder.spec.nodes.items():
|
|
60
|
+
for choice in node.choices:
|
|
61
|
+
if (node_id, choice.id) in trans_map:
|
|
62
|
+
continue
|
|
63
|
+
issues.append(
|
|
64
|
+
TreeSpecIssue(
|
|
65
|
+
level="error",
|
|
66
|
+
code="missing_transition",
|
|
67
|
+
message=f"Missing transition for choice '{choice.id}' on node '{node_id}'.",
|
|
68
|
+
node_id=node_id,
|
|
69
|
+
choice_id=choice.id,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return issues
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _find_missing_target_node_issues(
|
|
76
|
+
builder: TreeSpecBuilder,
|
|
77
|
+
trans_map: dict[tuple[str, str], str],
|
|
78
|
+
) -> list[TreeSpecIssue]:
|
|
79
|
+
issues: list[TreeSpecIssue] = []
|
|
80
|
+
for (from_node, choice_id), to_node in trans_map.items():
|
|
81
|
+
if to_node == END_NODE_ID or to_node in builder.spec.nodes:
|
|
82
|
+
continue
|
|
83
|
+
issues.append(
|
|
84
|
+
TreeSpecIssue(
|
|
85
|
+
level="error",
|
|
86
|
+
code="missing_target_node",
|
|
87
|
+
message=f"Transition ({from_node}, {choice_id}) points to missing node '{to_node}'.",
|
|
88
|
+
node_id=from_node,
|
|
89
|
+
choice_id=choice_id,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return issues
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _collect_reachable_nodes(
|
|
96
|
+
builder: TreeSpecBuilder,
|
|
97
|
+
trans_map: dict[tuple[str, str], str],
|
|
98
|
+
) -> set[str]:
|
|
99
|
+
reachable: set[str] = set()
|
|
100
|
+
stack: list[str] = [builder.get_start_node_id()]
|
|
101
|
+
while stack:
|
|
102
|
+
node_id = stack.pop()
|
|
103
|
+
if node_id in reachable:
|
|
104
|
+
continue
|
|
105
|
+
reachable.add(node_id)
|
|
106
|
+
current = builder.spec.nodes.get(node_id)
|
|
107
|
+
if current is None:
|
|
108
|
+
continue
|
|
109
|
+
for choice in current.choices:
|
|
110
|
+
next_node = trans_map.get((node_id, choice.id))
|
|
111
|
+
if not next_node or next_node == END_NODE_ID or next_node in reachable:
|
|
112
|
+
continue
|
|
113
|
+
stack.append(next_node)
|
|
114
|
+
return reachable
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _find_unreachable_node_issues(
|
|
118
|
+
builder: TreeSpecBuilder,
|
|
119
|
+
reachable: set[str],
|
|
120
|
+
) -> list[TreeSpecIssue]:
|
|
121
|
+
start_node_id = builder.get_start_node_id()
|
|
122
|
+
return [
|
|
123
|
+
TreeSpecIssue(
|
|
124
|
+
level="warning",
|
|
125
|
+
code="unreachable_node",
|
|
126
|
+
message=f"Node '{node_id}' is unreachable from start node '{start_node_id}'.",
|
|
127
|
+
node_id=node_id,
|
|
128
|
+
)
|
|
129
|
+
for node_id in builder.spec.nodes.keys()
|
|
130
|
+
if node_id not in reachable
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def lint_tree_spec(builder: TreeSpecBuilder) -> List[TreeSpecIssue]:
|
|
135
|
+
"""Domain linting beyond Pydantic shape validation.
|
|
136
|
+
|
|
137
|
+
The TreeSpec models enforce structural invariants, but lints catch authoring mistakes:
|
|
138
|
+
- missing transitions for choices
|
|
139
|
+
- transitions that point to missing nodes
|
|
140
|
+
- unreachable nodes
|
|
141
|
+
|
|
142
|
+
Keep these rules stable and re-use them across:
|
|
143
|
+
- seed_demo (--training-content-only)
|
|
144
|
+
- admin validate endpoint
|
|
145
|
+
- publish gating
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
duplicate_keys = _find_duplicate_transition_keys(builder)
|
|
149
|
+
trans_map = _build_transition_map(builder)
|
|
150
|
+
reachable = _collect_reachable_nodes(builder, trans_map)
|
|
151
|
+
|
|
152
|
+
return [
|
|
153
|
+
*_build_duplicate_transition_issues(duplicate_keys),
|
|
154
|
+
*_find_missing_transition_issues(builder, trans_map),
|
|
155
|
+
*_find_missing_target_node_issues(builder, trans_map),
|
|
156
|
+
*_find_unreachable_node_issues(builder, reachable),
|
|
157
|
+
]
|