roborean-storage-base 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_base-0.1.2/.gitignore +27 -0
- roborean_storage_base-0.1.2/CHANGELOG.md +15 -0
- roborean_storage_base-0.1.2/PKG-INFO +26 -0
- roborean_storage_base-0.1.2/README.md +10 -0
- roborean_storage_base-0.1.2/pyproject.toml +25 -0
- roborean_storage_base-0.1.2/src/roborean_storage_base/__init__.py +16 -0
- roborean_storage_base-0.1.2/src/roborean_storage_base/errors.py +17 -0
- roborean_storage_base-0.1.2/src/roborean_storage_base/ports.py +57 -0
- roborean_storage_base-0.1.2/src/roborean_storage_base/types.py +5 -0
- roborean_storage_base-0.1.2/tests/conftest.py +1 -0
- roborean_storage_base-0.1.2/tests/ports_test.py +147 -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
|
+
- `ProjectRepository`, `RunRepository`, and `ArtifactStore` ports with
|
|
8
|
+
shared storage error types for durable Phase 2 persistence.
|
|
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,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: roborean-storage-base
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Persistence ports for Roborean projects and runs
|
|
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: roborean-spec>=0.1.1
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: black>=24.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: flake8>=7.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: isort>=5.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# roborean-storage-base
|
|
18
|
+
|
|
19
|
+
Persistence ports for Roborean projects and durable runs.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from roborean_storage_base import ProjectRepository, RunRepository
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Adapters implement these protocols; the engine never writes files directly
|
|
26
|
+
from `runner.py`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# roborean-storage-base
|
|
2
|
+
|
|
3
|
+
Persistence ports for Roborean projects and durable runs.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from roborean_storage_base import ProjectRepository, RunRepository
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Adapters implement these protocols; the engine never writes files directly
|
|
10
|
+
from `runner.py`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "roborean-storage-base"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Persistence ports for Roborean projects and runs"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"roborean-spec>=0.1.1",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=8.0", "black>=24.0", "isort>=5.0", "flake8>=7.0"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/TNick/roborean"
|
|
22
|
+
Repository = "https://github.com/TNick/roborean"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/roborean_storage_base"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Persistence ports for Roborean projects and durable runs."""
|
|
2
|
+
|
|
3
|
+
from .errors import ConflictError, IntegrityError, NotFoundError, StorageError
|
|
4
|
+
from .ports import ArtifactStore, ProjectRepository, RunRepository
|
|
5
|
+
|
|
6
|
+
__version__ = "0.2.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ArtifactStore",
|
|
10
|
+
"ConflictError",
|
|
11
|
+
"IntegrityError",
|
|
12
|
+
"NotFoundError",
|
|
13
|
+
"ProjectRepository",
|
|
14
|
+
"RunRepository",
|
|
15
|
+
"StorageError",
|
|
16
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Shared storage failure types."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StorageError(Exception):
|
|
5
|
+
"""Base storage failure."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotFoundError(StorageError):
|
|
9
|
+
"""Entity missing from the store."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConflictError(StorageError):
|
|
13
|
+
"""Idempotency or optimistic-lock conflict."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IntegrityError(StorageError):
|
|
17
|
+
"""Corrupt or incomplete package."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Repository and artifact store protocols."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from roborean_spec import Project, RunRecord
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProjectRepository(Protocol):
|
|
9
|
+
"""Load and save portable projects by identifier."""
|
|
10
|
+
|
|
11
|
+
def get(self, project_id: str) -> Project:
|
|
12
|
+
"""Return the current revision of a project."""
|
|
13
|
+
|
|
14
|
+
def get_revision(self, project_id: str, revision: str) -> Project:
|
|
15
|
+
"""Return a pinned project revision."""
|
|
16
|
+
|
|
17
|
+
def save(self, project: Project, *, revision: str | None = None) -> str:
|
|
18
|
+
"""Persist a project and return the revision identifier."""
|
|
19
|
+
|
|
20
|
+
def list_ids(self) -> list[str]:
|
|
21
|
+
"""List stored project identifiers."""
|
|
22
|
+
|
|
23
|
+
def delete(self, project_id: str) -> None:
|
|
24
|
+
"""Remove a project and its revisions."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RunRepository(Protocol):
|
|
28
|
+
"""Persist durable run records with idempotency lookup."""
|
|
29
|
+
|
|
30
|
+
def get(self, run_id: str) -> RunRecord:
|
|
31
|
+
"""Return one run by identifier."""
|
|
32
|
+
|
|
33
|
+
def get_by_idempotency(
|
|
34
|
+
self, project_id: str, idempotency_key: str
|
|
35
|
+
) -> RunRecord | None:
|
|
36
|
+
"""Return an existing run for an idempotency key, if any."""
|
|
37
|
+
|
|
38
|
+
def save(self, record: RunRecord) -> None:
|
|
39
|
+
"""Insert a new run record."""
|
|
40
|
+
|
|
41
|
+
def update(self, record: RunRecord) -> None:
|
|
42
|
+
"""Replace an existing run record."""
|
|
43
|
+
|
|
44
|
+
def list_for_project(
|
|
45
|
+
self, project_id: str, *, limit: int = 50
|
|
46
|
+
) -> list[RunRecord]:
|
|
47
|
+
"""List recent runs for a project, newest first."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ArtifactStore(Protocol):
|
|
51
|
+
"""Store opaque binary artifacts referenced by runs."""
|
|
52
|
+
|
|
53
|
+
def put_bytes(self, key: str, data: bytes, *, content_type: str) -> str:
|
|
54
|
+
"""Store bytes and return the storage key."""
|
|
55
|
+
|
|
56
|
+
def get_bytes(self, key: str) -> bytes:
|
|
57
|
+
"""Load previously stored bytes."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared fixtures for storage-base tests."""
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Document expected storage port semantics with an in-memory fake."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from roborean_spec import (
|
|
5
|
+
Project,
|
|
6
|
+
RunRecord,
|
|
7
|
+
RunRequest,
|
|
8
|
+
RunStatus,
|
|
9
|
+
RunTrigger,
|
|
10
|
+
project_from_dict,
|
|
11
|
+
)
|
|
12
|
+
from roborean_storage_base import ConflictError, NotFoundError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MemoryProjectRepository:
|
|
16
|
+
"""Minimal in-memory project repository for contract tests."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
"""Create an empty store."""
|
|
20
|
+
self._projects: dict[str, Project] = {}
|
|
21
|
+
self._revisions: dict[tuple[str, str], Project] = {}
|
|
22
|
+
|
|
23
|
+
def get(self, project_id: str) -> Project:
|
|
24
|
+
"""Return the current project or raise."""
|
|
25
|
+
if project_id not in self._projects:
|
|
26
|
+
raise NotFoundError(project_id)
|
|
27
|
+
return self._projects[project_id]
|
|
28
|
+
|
|
29
|
+
def get_revision(self, project_id: str, revision: str) -> Project:
|
|
30
|
+
"""Return a pinned revision or raise."""
|
|
31
|
+
key = (project_id, revision)
|
|
32
|
+
if key not in self._revisions:
|
|
33
|
+
raise NotFoundError(f"{project_id}@{revision}")
|
|
34
|
+
return self._revisions[key]
|
|
35
|
+
|
|
36
|
+
def save(self, project: Project, *, revision: str | None = None) -> str:
|
|
37
|
+
"""Store the project under a revision."""
|
|
38
|
+
rev = revision or "1"
|
|
39
|
+
self._projects[project.id] = project
|
|
40
|
+
self._revisions[(project.id, rev)] = project
|
|
41
|
+
return rev
|
|
42
|
+
|
|
43
|
+
def list_ids(self) -> list[str]:
|
|
44
|
+
"""List project ids."""
|
|
45
|
+
return sorted(self._projects)
|
|
46
|
+
|
|
47
|
+
def delete(self, project_id: str) -> None:
|
|
48
|
+
"""Delete one project."""
|
|
49
|
+
self._projects.pop(project_id, None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MemoryRunRepository:
|
|
53
|
+
"""Minimal in-memory run repository for contract tests."""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
"""Create an empty store."""
|
|
57
|
+
self._by_id: dict[str, RunRecord] = {}
|
|
58
|
+
self._by_key: dict[tuple[str, str], str] = {}
|
|
59
|
+
|
|
60
|
+
def get(self, run_id: str) -> RunRecord:
|
|
61
|
+
"""Return one run or raise."""
|
|
62
|
+
if run_id not in self._by_id:
|
|
63
|
+
raise NotFoundError(run_id)
|
|
64
|
+
return self._by_id[run_id]
|
|
65
|
+
|
|
66
|
+
def get_by_idempotency(
|
|
67
|
+
self, project_id: str, idempotency_key: str
|
|
68
|
+
) -> RunRecord | None:
|
|
69
|
+
"""Lookup by idempotency key."""
|
|
70
|
+
run_id = self._by_key.get((project_id, idempotency_key))
|
|
71
|
+
return None if run_id is None else self._by_id[run_id]
|
|
72
|
+
|
|
73
|
+
def save(self, record: RunRecord) -> None:
|
|
74
|
+
"""Insert a run, rejecting duplicate keys."""
|
|
75
|
+
key = (record.project_id, record.idempotency_key)
|
|
76
|
+
if key in self._by_key:
|
|
77
|
+
raise ConflictError("duplicate idempotency key")
|
|
78
|
+
self._by_id[record.run_id] = record
|
|
79
|
+
self._by_key[key] = record.run_id
|
|
80
|
+
|
|
81
|
+
def update(self, record: RunRecord) -> None:
|
|
82
|
+
"""Replace an existing run."""
|
|
83
|
+
if record.run_id not in self._by_id:
|
|
84
|
+
raise NotFoundError(record.run_id)
|
|
85
|
+
self._by_id[record.run_id] = record
|
|
86
|
+
|
|
87
|
+
def list_for_project(
|
|
88
|
+
self, project_id: str, *, limit: int = 50
|
|
89
|
+
) -> list[RunRecord]:
|
|
90
|
+
"""List runs for one project."""
|
|
91
|
+
items = [
|
|
92
|
+
item
|
|
93
|
+
for item in self._by_id.values()
|
|
94
|
+
if item.project_id == project_id
|
|
95
|
+
]
|
|
96
|
+
return items[:limit]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestMemoryPorts:
|
|
100
|
+
"""Contract checks for the in-memory fakes."""
|
|
101
|
+
|
|
102
|
+
def test_project_missing_raises(self) -> None:
|
|
103
|
+
"""Missing projects raise NotFoundError."""
|
|
104
|
+
repo = MemoryProjectRepository()
|
|
105
|
+
with pytest.raises(NotFoundError):
|
|
106
|
+
repo.get("missing")
|
|
107
|
+
|
|
108
|
+
def test_run_idempotency_conflict(self) -> None:
|
|
109
|
+
"""Duplicate idempotency keys conflict on save."""
|
|
110
|
+
project = project_from_dict(
|
|
111
|
+
{
|
|
112
|
+
"schemaVersion": "1.0.0",
|
|
113
|
+
"id": "p1",
|
|
114
|
+
"name": "P",
|
|
115
|
+
"pluginRequirements": [],
|
|
116
|
+
"workspace": {"variables": []},
|
|
117
|
+
"bits": [],
|
|
118
|
+
"documents": [],
|
|
119
|
+
"templates": [],
|
|
120
|
+
"metadata": {},
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
projects = MemoryProjectRepository()
|
|
124
|
+
projects.save(project, revision="1")
|
|
125
|
+
runs = MemoryRunRepository()
|
|
126
|
+
request = RunRequest(
|
|
127
|
+
projectId="p1",
|
|
128
|
+
idempotencyKey="k1",
|
|
129
|
+
trigger=RunTrigger.TEST,
|
|
130
|
+
)
|
|
131
|
+
record = RunRecord(
|
|
132
|
+
runId="r1",
|
|
133
|
+
idempotencyKey="k1",
|
|
134
|
+
projectId="p1",
|
|
135
|
+
projectRevision="1",
|
|
136
|
+
compiledDigest="d",
|
|
137
|
+
status=RunStatus.QUEUED,
|
|
138
|
+
request=request,
|
|
139
|
+
attempt=1,
|
|
140
|
+
engineVersion="0.2.0",
|
|
141
|
+
pluginVersions={},
|
|
142
|
+
createdAt="2026-01-01T00:00:00+00:00",
|
|
143
|
+
requestDigest="abc",
|
|
144
|
+
)
|
|
145
|
+
runs.save(record)
|
|
146
|
+
with pytest.raises(ConflictError):
|
|
147
|
+
runs.save(record.model_copy(update={"run_id": "r2"}))
|