roborean-storage-dict 0.1.2__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.
- roborean_storage_dict-0.1.2/.gitignore +27 -0
- roborean_storage_dict-0.1.2/CHANGELOG.md +15 -0
- roborean_storage_dict-0.1.2/PKG-INFO +30 -0
- roborean_storage_dict-0.1.2/README.md +12 -0
- roborean_storage_dict-0.1.2/pyproject.toml +27 -0
- roborean_storage_dict-0.1.2/src/roborean_storage_dict/__init__.py +21 -0
- roborean_storage_dict-0.1.2/src/roborean_storage_dict/json_store.py +228 -0
- roborean_storage_dict-0.1.2/src/roborean_storage_dict/project_package.py +82 -0
- roborean_storage_dict-0.1.2/src/roborean_storage_dict/yaml_store.py +28 -0
- roborean_storage_dict-0.1.2/tests/conftest.py +59 -0
- roborean_storage_dict-0.1.2/tests/project_package_test.py +59 -0
- roborean_storage_dict-0.1.2/tests/run_store_test.py +52 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
venv/
|
|
2
|
+
venv-qt5/
|
|
3
|
+
venv-qt6/
|
|
4
|
+
.venv/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.coverage
|
|
10
|
+
htmlcov/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
*.egg-info/
|
|
14
|
+
*.tsbuildinfo
|
|
15
|
+
node_modules/
|
|
16
|
+
.pnpm-store/
|
|
17
|
+
.DS_Store
|
|
18
|
+
.idea/
|
|
19
|
+
.vscode/*.local
|
|
20
|
+
playground/
|
|
21
|
+
.e2e-ai/
|
|
22
|
+
**/test-results/
|
|
23
|
+
**/playwright-report/
|
|
24
|
+
research/
|
|
25
|
+
.roborean/
|
|
26
|
+
.roborean-sql-artifacts/
|
|
27
|
+
*.db
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Filesystem project package loader/saver with JSON and YAML (`safe_load`)
|
|
8
|
+
support, plus durable run artifact directories and idempotency indexes.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Lower inter-package dependency pins to `>=0.1.1` to match lockstep
|
|
15
|
+
release versions.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: roborean-storage-dict
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: JSON/YAML filesystem persistence for Roborean
|
|
5
|
+
Project-URL: Homepage, https://github.com/TNick/roborean
|
|
6
|
+
Project-URL: Repository, https://github.com/TNick/roborean
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: pyyaml>=6.0
|
|
10
|
+
Requires-Dist: roborean-spec>=0.1.1
|
|
11
|
+
Requires-Dist: roborean-storage-base>=0.1.1
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: black>=24.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: flake8>=7.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: isort>=5.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# roborean-storage-dict
|
|
20
|
+
|
|
21
|
+
Filesystem JSON/YAML persistence for projects and runs.
|
|
22
|
+
|
|
23
|
+
YAML loading uses `yaml.safe_load` only.
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from roborean_storage_dict import DictProjectRepository, DictRunRepository
|
|
27
|
+
|
|
28
|
+
projects = DictProjectRepository(Path(".roborean"))
|
|
29
|
+
runs = DictRunRepository(Path(".roborean"))
|
|
30
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# roborean-storage-dict
|
|
2
|
+
|
|
3
|
+
Filesystem JSON/YAML persistence for projects and runs.
|
|
4
|
+
|
|
5
|
+
YAML loading uses `yaml.safe_load` only.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from roborean_storage_dict import DictProjectRepository, DictRunRepository
|
|
9
|
+
|
|
10
|
+
projects = DictProjectRepository(Path(".roborean"))
|
|
11
|
+
runs = DictRunRepository(Path(".roborean"))
|
|
12
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "roborean-storage-dict"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "JSON/YAML filesystem persistence for Roborean"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"PyYAML>=6.0",
|
|
14
|
+
"roborean-spec>=0.1.1",
|
|
15
|
+
"roborean-storage-base>=0.1.1",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=8.0", "black>=24.0", "isort>=5.0", "flake8>=7.0"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/TNick/roborean"
|
|
24
|
+
Repository = "https://github.com/TNick/roborean"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/roborean_storage_dict"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Filesystem JSON/YAML persistence for Roborean."""
|
|
2
|
+
|
|
3
|
+
from .json_store import (
|
|
4
|
+
DictArtifactStore,
|
|
5
|
+
DictProjectRepository,
|
|
6
|
+
DictRunRepository,
|
|
7
|
+
)
|
|
8
|
+
from .project_package import load_project_dir, save_project_dir
|
|
9
|
+
from .yaml_store import dump_yaml, load_yaml
|
|
10
|
+
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DictArtifactStore",
|
|
15
|
+
"DictProjectRepository",
|
|
16
|
+
"DictRunRepository",
|
|
17
|
+
"dump_yaml",
|
|
18
|
+
"load_project_dir",
|
|
19
|
+
"load_yaml",
|
|
20
|
+
"save_project_dir",
|
|
21
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Dictionary/filesystem repositories for projects and runs."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from roborean_spec import Project, RunRecord, project_from_dict
|
|
8
|
+
from roborean_storage_base import ConflictError, NotFoundError
|
|
9
|
+
|
|
10
|
+
from .project_package import load_project_dir, save_project_dir, write_revision
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_json(path: Path) -> dict:
|
|
16
|
+
"""Load a JSON object from disk."""
|
|
17
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
21
|
+
"""Write a JSON object with a trailing newline."""
|
|
22
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
path.write_text(
|
|
24
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
|
25
|
+
encoding="utf-8",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DictProjectRepository:
|
|
30
|
+
"""Store projects under ``<root>/projects/<id>/``."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, root: Path) -> None:
|
|
33
|
+
"""Create repositories rooted at ``root``."""
|
|
34
|
+
self.root = root
|
|
35
|
+
self._projects = root / "projects"
|
|
36
|
+
|
|
37
|
+
def _project_dir(self, project_id: str) -> Path:
|
|
38
|
+
"""Return the package directory for one project."""
|
|
39
|
+
return self._projects / project_id
|
|
40
|
+
|
|
41
|
+
def get(self, project_id: str) -> Project:
|
|
42
|
+
"""Load the current project package."""
|
|
43
|
+
package_dir = self._project_dir(project_id)
|
|
44
|
+
if not package_dir.is_dir():
|
|
45
|
+
raise NotFoundError(project_id)
|
|
46
|
+
return load_project_dir(package_dir)
|
|
47
|
+
|
|
48
|
+
def get_revision(self, project_id: str, revision: str) -> Project:
|
|
49
|
+
"""Load a pinned revision."""
|
|
50
|
+
path = (
|
|
51
|
+
self._project_dir(project_id)
|
|
52
|
+
/ "revisions"
|
|
53
|
+
/ revision
|
|
54
|
+
/ "project.json"
|
|
55
|
+
)
|
|
56
|
+
if not path.is_file():
|
|
57
|
+
raise NotFoundError(f"{project_id}@{revision}")
|
|
58
|
+
return project_from_dict(_read_json(path))
|
|
59
|
+
|
|
60
|
+
def save(self, project: Project, *, revision: str | None = None) -> str:
|
|
61
|
+
"""Save the current package and optionally a revision snapshot."""
|
|
62
|
+
rev = revision or "1"
|
|
63
|
+
package_dir = self._project_dir(project.id)
|
|
64
|
+
save_project_dir(package_dir, project)
|
|
65
|
+
write_revision(package_dir, rev, project)
|
|
66
|
+
meta_path = package_dir / "current_revision.txt"
|
|
67
|
+
meta_path.write_text(rev + "\n", encoding="utf-8")
|
|
68
|
+
return rev
|
|
69
|
+
|
|
70
|
+
def list_ids(self) -> list[str]:
|
|
71
|
+
"""List project package directory names."""
|
|
72
|
+
if not self._projects.is_dir():
|
|
73
|
+
return []
|
|
74
|
+
return sorted(
|
|
75
|
+
path.name for path in self._projects.iterdir() if path.is_dir()
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def delete(self, project_id: str) -> None:
|
|
79
|
+
"""Remove a project package tree."""
|
|
80
|
+
package_dir = self._project_dir(project_id)
|
|
81
|
+
if not package_dir.exists():
|
|
82
|
+
raise NotFoundError(project_id)
|
|
83
|
+
for path in sorted(package_dir.rglob("*"), reverse=True):
|
|
84
|
+
if path.is_file():
|
|
85
|
+
path.unlink()
|
|
86
|
+
elif path.is_dir():
|
|
87
|
+
path.rmdir()
|
|
88
|
+
package_dir.rmdir()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class DictRunRepository:
|
|
92
|
+
"""Store runs under ``<root>/runs/<projectId>/<runId>/``."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, root: Path) -> None:
|
|
95
|
+
"""Create a run repository under ``root``."""
|
|
96
|
+
self.root = root
|
|
97
|
+
self._runs = root / "runs"
|
|
98
|
+
self._idempotency = root / "idempotency"
|
|
99
|
+
|
|
100
|
+
def _run_dir(self, project_id: str, run_id: str) -> Path:
|
|
101
|
+
"""Return the artifact directory for one run."""
|
|
102
|
+
return self._runs / project_id / run_id
|
|
103
|
+
|
|
104
|
+
def _idempotency_path(self, project_id: str, key: str) -> Path:
|
|
105
|
+
"""Return the idempotency index file path."""
|
|
106
|
+
safe = key.replace(":", "_")
|
|
107
|
+
return self._idempotency / project_id / f"{safe}.json"
|
|
108
|
+
|
|
109
|
+
def get(self, run_id: str) -> RunRecord:
|
|
110
|
+
"""Find a run by scanning project run directories."""
|
|
111
|
+
if not self._runs.is_dir():
|
|
112
|
+
raise NotFoundError(run_id)
|
|
113
|
+
for project_dir in self._runs.iterdir():
|
|
114
|
+
candidate = project_dir / run_id / "run-record.json"
|
|
115
|
+
if candidate.is_file():
|
|
116
|
+
return RunRecord.model_validate(_read_json(candidate))
|
|
117
|
+
raise NotFoundError(run_id)
|
|
118
|
+
|
|
119
|
+
def get_by_idempotency(
|
|
120
|
+
self, project_id: str, idempotency_key: str
|
|
121
|
+
) -> RunRecord | None:
|
|
122
|
+
"""Resolve an idempotency key to a run record."""
|
|
123
|
+
index = self._idempotency_path(project_id, idempotency_key)
|
|
124
|
+
if not index.is_file():
|
|
125
|
+
return None
|
|
126
|
+
payload = _read_json(index)
|
|
127
|
+
run_id = payload["runId"]
|
|
128
|
+
try:
|
|
129
|
+
return self.get(run_id)
|
|
130
|
+
except NotFoundError:
|
|
131
|
+
logger.debug(
|
|
132
|
+
"Idempotency index pointed at missing run %s",
|
|
133
|
+
run_id,
|
|
134
|
+
exc_info=True,
|
|
135
|
+
)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
def save(self, record: RunRecord) -> None:
|
|
139
|
+
"""Insert a new run and its idempotency index entry."""
|
|
140
|
+
existing = self.get_by_idempotency(
|
|
141
|
+
record.project_id, record.idempotency_key
|
|
142
|
+
)
|
|
143
|
+
if existing is not None:
|
|
144
|
+
if existing.request_digest != record.request_digest:
|
|
145
|
+
raise ConflictError(
|
|
146
|
+
"idempotency key reused with a different request body"
|
|
147
|
+
)
|
|
148
|
+
raise ConflictError("idempotency key already exists")
|
|
149
|
+
self._write_record(record)
|
|
150
|
+
_write_json(
|
|
151
|
+
self._idempotency_path(record.project_id, record.idempotency_key),
|
|
152
|
+
{
|
|
153
|
+
"runId": record.run_id,
|
|
154
|
+
"createdAt": record.created_at,
|
|
155
|
+
"requestDigest": record.request_digest,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def update(self, record: RunRecord) -> None:
|
|
160
|
+
"""Overwrite an existing run artifact set."""
|
|
161
|
+
run_dir = self._run_dir(record.project_id, record.run_id)
|
|
162
|
+
if not (run_dir / "run-record.json").is_file():
|
|
163
|
+
raise NotFoundError(record.run_id)
|
|
164
|
+
self._write_record(record)
|
|
165
|
+
|
|
166
|
+
def list_for_project(
|
|
167
|
+
self, project_id: str, *, limit: int = 50
|
|
168
|
+
) -> list[RunRecord]:
|
|
169
|
+
"""List runs for a project, newest first by createdAt."""
|
|
170
|
+
project_dir = self._runs / project_id
|
|
171
|
+
if not project_dir.is_dir():
|
|
172
|
+
return []
|
|
173
|
+
records: list[RunRecord] = []
|
|
174
|
+
for run_dir in project_dir.iterdir():
|
|
175
|
+
path = run_dir / "run-record.json"
|
|
176
|
+
if path.is_file():
|
|
177
|
+
records.append(RunRecord.model_validate(_read_json(path)))
|
|
178
|
+
records.sort(key=lambda item: item.created_at, reverse=True)
|
|
179
|
+
return records[:limit]
|
|
180
|
+
|
|
181
|
+
def _write_record(self, record: RunRecord) -> None:
|
|
182
|
+
"""Write run-record and optional result/diff sidecars."""
|
|
183
|
+
run_dir = self._run_dir(record.project_id, record.run_id)
|
|
184
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
_write_json(
|
|
186
|
+
run_dir / "run-record.json",
|
|
187
|
+
record.model_dump(mode="json", by_alias=True, exclude_none=True),
|
|
188
|
+
)
|
|
189
|
+
if record.results is not None:
|
|
190
|
+
_write_json(
|
|
191
|
+
run_dir / "run-results.json",
|
|
192
|
+
record.results.model_dump(
|
|
193
|
+
mode="json", by_alias=True, exclude_none=True
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
if record.diff is not None:
|
|
197
|
+
_write_json(
|
|
198
|
+
run_dir / "run-diff.json",
|
|
199
|
+
record.diff.model_dump(
|
|
200
|
+
mode="json", by_alias=True, exclude_none=True
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class DictArtifactStore:
|
|
206
|
+
"""Store opaque artifacts under ``<root>/artifacts/``."""
|
|
207
|
+
|
|
208
|
+
def __init__(self, root: Path) -> None:
|
|
209
|
+
"""Create an artifact store under ``root``."""
|
|
210
|
+
self.root = root / "artifacts"
|
|
211
|
+
|
|
212
|
+
def put_bytes(self, key: str, data: bytes, *, content_type: str) -> str:
|
|
213
|
+
"""Write artifact bytes and a small sidecar for content type."""
|
|
214
|
+
path = self.root / key
|
|
215
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
path.write_bytes(data)
|
|
217
|
+
_write_json(
|
|
218
|
+
path.with_suffix(path.suffix + ".meta.json"),
|
|
219
|
+
{"contentType": content_type},
|
|
220
|
+
)
|
|
221
|
+
return key
|
|
222
|
+
|
|
223
|
+
def get_bytes(self, key: str) -> bytes:
|
|
224
|
+
"""Read artifact bytes."""
|
|
225
|
+
path = self.root / key
|
|
226
|
+
if not path.is_file():
|
|
227
|
+
raise NotFoundError(key)
|
|
228
|
+
return path.read_bytes()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Load and save on-disk project packages."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from roborean_spec import (
|
|
7
|
+
CompiledProject,
|
|
8
|
+
Project,
|
|
9
|
+
project_from_dict,
|
|
10
|
+
project_to_dict,
|
|
11
|
+
)
|
|
12
|
+
from roborean_storage_base import IntegrityError, NotFoundError
|
|
13
|
+
|
|
14
|
+
from .yaml_store import dump_yaml, load_yaml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def detect_entry(package_dir: Path) -> Path:
|
|
18
|
+
"""Return the project entry file inside a package directory."""
|
|
19
|
+
json_path = package_dir / "project.json"
|
|
20
|
+
yaml_path = package_dir / "project.yaml"
|
|
21
|
+
if json_path.is_file():
|
|
22
|
+
return json_path
|
|
23
|
+
if yaml_path.is_file():
|
|
24
|
+
return yaml_path
|
|
25
|
+
raise NotFoundError(f"No project.json or project.yaml in {package_dir}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_project_dir(package_dir: Path) -> Project:
|
|
29
|
+
"""Load a project from a package directory."""
|
|
30
|
+
entry = detect_entry(package_dir)
|
|
31
|
+
if entry.suffix.lower() == ".yaml":
|
|
32
|
+
data = load_yaml(entry)
|
|
33
|
+
else:
|
|
34
|
+
data = json.loads(entry.read_text(encoding="utf-8"))
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
raise IntegrityError(f"Project entry is not an object: {entry}")
|
|
37
|
+
return project_from_dict(data)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def save_project_dir(
|
|
41
|
+
package_dir: Path,
|
|
42
|
+
project: Project,
|
|
43
|
+
*,
|
|
44
|
+
as_yaml: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Write a project package entry file."""
|
|
47
|
+
package_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
data = project_to_dict(project)
|
|
49
|
+
if as_yaml:
|
|
50
|
+
dump_yaml(package_dir / "project.yaml", data)
|
|
51
|
+
return
|
|
52
|
+
path = package_dir / "project.json"
|
|
53
|
+
path.write_text(
|
|
54
|
+
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_revision(
|
|
60
|
+
package_dir: Path,
|
|
61
|
+
revision: str,
|
|
62
|
+
project: Project,
|
|
63
|
+
compiled: CompiledProject | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Persist one immutable project revision under revisions/."""
|
|
66
|
+
revision_dir = package_dir / "revisions" / revision
|
|
67
|
+
revision_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
(revision_dir / "project.json").write_text(
|
|
69
|
+
json.dumps(project_to_dict(project), indent=2, ensure_ascii=False)
|
|
70
|
+
+ "\n",
|
|
71
|
+
encoding="utf-8",
|
|
72
|
+
)
|
|
73
|
+
if compiled is not None:
|
|
74
|
+
(revision_dir / "compiled-project.json").write_text(
|
|
75
|
+
json.dumps(
|
|
76
|
+
compiled.model_dump(mode="json", by_alias=True),
|
|
77
|
+
indent=2,
|
|
78
|
+
ensure_ascii=False,
|
|
79
|
+
)
|
|
80
|
+
+ "\n",
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""YAML helpers that only use safe_load."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from roborean_storage_base import IntegrityError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_yaml(path: Path) -> dict[str, Any]:
|
|
11
|
+
"""Load YAML using yaml.safe_load only."""
|
|
12
|
+
# Reject non-mapping roots so package files stay object-shaped.
|
|
13
|
+
try:
|
|
14
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
15
|
+
except yaml.YAMLError as error:
|
|
16
|
+
raise IntegrityError(f"Invalid YAML: {path}") from error
|
|
17
|
+
if not isinstance(data, dict):
|
|
18
|
+
raise IntegrityError(f"YAML root must be a mapping: {path}")
|
|
19
|
+
return data
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dump_yaml(path: Path, data: dict[str, Any]) -> None:
|
|
23
|
+
"""Write a YAML mapping with a trailing newline."""
|
|
24
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
path.write_text(
|
|
26
|
+
yaml.safe_dump(data, sort_keys=False, allow_unicode=True),
|
|
27
|
+
encoding="utf-8",
|
|
28
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Shared fixtures for dict storage tests."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from roborean_spec import project_from_dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def minimal_project():
|
|
11
|
+
"""Return a tiny valid project model."""
|
|
12
|
+
return project_from_dict(
|
|
13
|
+
{
|
|
14
|
+
"schemaVersion": "1.0.0",
|
|
15
|
+
"id": "example.minimal",
|
|
16
|
+
"name": "Minimal",
|
|
17
|
+
"pluginRequirements": [],
|
|
18
|
+
"workspace": {
|
|
19
|
+
"variables": [
|
|
20
|
+
{
|
|
21
|
+
"key": "title",
|
|
22
|
+
"schema": {"type": "string"},
|
|
23
|
+
"defaultValue": {
|
|
24
|
+
"kind": "public_literal",
|
|
25
|
+
"dataType": "string",
|
|
26
|
+
"value": "Hello",
|
|
27
|
+
},
|
|
28
|
+
"const": False,
|
|
29
|
+
"exposure": "clientVisible",
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"bits": [
|
|
34
|
+
{
|
|
35
|
+
"id": "b1",
|
|
36
|
+
"type": "roborean.noop",
|
|
37
|
+
"when": True,
|
|
38
|
+
"config": {},
|
|
39
|
+
"reads": [],
|
|
40
|
+
"writes": [],
|
|
41
|
+
"emits": [],
|
|
42
|
+
"effectClass": "pure",
|
|
43
|
+
"onError": "abort",
|
|
44
|
+
"capabilities": [],
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"documents": [],
|
|
48
|
+
"templates": [],
|
|
49
|
+
"metadata": {},
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def store_root(tmp_path: Path) -> Path:
|
|
56
|
+
"""Return a temporary store root directory."""
|
|
57
|
+
root = tmp_path / "store"
|
|
58
|
+
root.mkdir()
|
|
59
|
+
return root
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Tests for on-disk project packages."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import yaml
|
|
7
|
+
from roborean_storage_base import IntegrityError
|
|
8
|
+
from roborean_storage_dict import (
|
|
9
|
+
DictProjectRepository,
|
|
10
|
+
dump_yaml,
|
|
11
|
+
load_project_dir,
|
|
12
|
+
load_yaml,
|
|
13
|
+
save_project_dir,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestProjectPackage:
|
|
18
|
+
"""Round-trip package loading."""
|
|
19
|
+
|
|
20
|
+
def test_json_roundtrip(self, tmp_path: Path, minimal_project) -> None:
|
|
21
|
+
"""JSON packages reload identically."""
|
|
22
|
+
package = tmp_path / "pkg"
|
|
23
|
+
save_project_dir(package, minimal_project)
|
|
24
|
+
loaded = load_project_dir(package)
|
|
25
|
+
assert loaded.id == minimal_project.id
|
|
26
|
+
assert loaded.name == minimal_project.name
|
|
27
|
+
|
|
28
|
+
def test_yaml_roundtrip(self, tmp_path: Path, minimal_project) -> None:
|
|
29
|
+
"""YAML packages reload identically."""
|
|
30
|
+
package = tmp_path / "pkg"
|
|
31
|
+
save_project_dir(package, minimal_project, as_yaml=True)
|
|
32
|
+
loaded = load_project_dir(package)
|
|
33
|
+
assert loaded.id == minimal_project.id
|
|
34
|
+
|
|
35
|
+
def test_repo_save_get(self, store_root: Path, minimal_project) -> None:
|
|
36
|
+
"""Dict project repository saves and loads."""
|
|
37
|
+
repo = DictProjectRepository(store_root)
|
|
38
|
+
rev = repo.save(minimal_project, revision="1")
|
|
39
|
+
assert rev == "1"
|
|
40
|
+
assert repo.get(minimal_project.id).id == minimal_project.id
|
|
41
|
+
loaded = repo.get_revision(minimal_project.id, "1")
|
|
42
|
+
assert loaded.id == minimal_project.id
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestYamlSafe:
|
|
46
|
+
"""YAML loading must reject unsafe tags."""
|
|
47
|
+
|
|
48
|
+
def test_rejects_python_object_tag(self, tmp_path: Path) -> None:
|
|
49
|
+
"""Python object tags are rejected by safe_load."""
|
|
50
|
+
path = tmp_path / "evil.yaml"
|
|
51
|
+
path.write_text("!!python/object/apply:os.system ['echo hi']\n")
|
|
52
|
+
with pytest.raises((IntegrityError, yaml.YAMLError)):
|
|
53
|
+
load_yaml(path)
|
|
54
|
+
|
|
55
|
+
def test_accepts_mapping(self, tmp_path: Path) -> None:
|
|
56
|
+
"""Plain mappings load."""
|
|
57
|
+
path = tmp_path / "ok.yaml"
|
|
58
|
+
dump_yaml(path, {"a": 1})
|
|
59
|
+
assert load_yaml(path) == {"a": 1}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tests for durable run artifact storage."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from roborean_spec import (
|
|
5
|
+
RunRecord,
|
|
6
|
+
RunRequest,
|
|
7
|
+
RunStatus,
|
|
8
|
+
RunTrigger,
|
|
9
|
+
)
|
|
10
|
+
from roborean_storage_base import ConflictError
|
|
11
|
+
from roborean_storage_dict import DictRunRepository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _record(run_id: str, key: str = "k1", digest: str = "abc") -> RunRecord:
|
|
15
|
+
"""Build a minimal queued run record."""
|
|
16
|
+
return RunRecord(
|
|
17
|
+
runId=run_id,
|
|
18
|
+
idempotencyKey=key,
|
|
19
|
+
projectId="example.minimal",
|
|
20
|
+
projectRevision="1",
|
|
21
|
+
compiledDigest="",
|
|
22
|
+
status=RunStatus.QUEUED,
|
|
23
|
+
request=RunRequest(
|
|
24
|
+
projectId="example.minimal",
|
|
25
|
+
idempotencyKey=key,
|
|
26
|
+
trigger=RunTrigger.TEST,
|
|
27
|
+
),
|
|
28
|
+
attempt=1,
|
|
29
|
+
engineVersion="0.2.0",
|
|
30
|
+
pluginVersions={},
|
|
31
|
+
createdAt="2026-01-01T00:00:00+00:00",
|
|
32
|
+
requestDigest=digest,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestDictRunRepository:
|
|
37
|
+
"""Filesystem run repository behavior."""
|
|
38
|
+
|
|
39
|
+
def test_save_and_get(self, store_root) -> None:
|
|
40
|
+
"""Runs persist under project/run directories."""
|
|
41
|
+
repo = DictRunRepository(store_root)
|
|
42
|
+
repo.save(_record("r1"))
|
|
43
|
+
loaded = repo.get("r1")
|
|
44
|
+
assert loaded.run_id == "r1"
|
|
45
|
+
assert repo.get_by_idempotency("example.minimal", "k1").run_id == "r1"
|
|
46
|
+
|
|
47
|
+
def test_idempotency_conflict_on_digest_mismatch(self, store_root) -> None:
|
|
48
|
+
"""Same key with a different body digest conflicts."""
|
|
49
|
+
repo = DictRunRepository(store_root)
|
|
50
|
+
repo.save(_record("r1", digest="aaa"))
|
|
51
|
+
with pytest.raises(ConflictError):
|
|
52
|
+
repo.save(_record("r2", digest="bbb"))
|