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.
@@ -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"))