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.
@@ -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,5 @@
1
+ """Re-exports used by storage adapters."""
2
+
3
+ from roborean_spec import CompiledProject, Project, RunRecord, RunResults
4
+
5
+ __all__ = ["CompiledProject", "Project", "RunRecord", "RunResults"]
@@ -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"}))