roborean-storage-sqlalchemy 0.1.2__py3-none-any.whl
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_sqlalchemy/__init__.py +18 -0
- roborean_storage_sqlalchemy/mappers.py +157 -0
- roborean_storage_sqlalchemy/migrate.py +29 -0
- roborean_storage_sqlalchemy/models.py +81 -0
- roborean_storage_sqlalchemy/project_repo.py +80 -0
- roborean_storage_sqlalchemy/run_repo.py +103 -0
- roborean_storage_sqlalchemy/session.py +21 -0
- roborean_storage_sqlalchemy-0.1.2.dist-info/METADATA +36 -0
- roborean_storage_sqlalchemy-0.1.2.dist-info/RECORD +10 -0
- roborean_storage_sqlalchemy-0.1.2.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""SQLAlchemy persistence adapter for Roborean."""
|
|
2
|
+
|
|
3
|
+
from .migrate import SCHEMA_VERSION, upgrade
|
|
4
|
+
from .project_repo import SqlAlchemyProjectRepository
|
|
5
|
+
from .run_repo import SqlAlchemyRunRepository
|
|
6
|
+
from .session import create_all, make_engine, make_session_factory
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"SCHEMA_VERSION",
|
|
12
|
+
"SqlAlchemyProjectRepository",
|
|
13
|
+
"SqlAlchemyRunRepository",
|
|
14
|
+
"create_all",
|
|
15
|
+
"make_engine",
|
|
16
|
+
"make_session_factory",
|
|
17
|
+
"upgrade",
|
|
18
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Map between ORM rows and Pydantic domain models."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
from roborean_spec import (
|
|
7
|
+
Project,
|
|
8
|
+
RunDiff,
|
|
9
|
+
RunError,
|
|
10
|
+
RunRecord,
|
|
11
|
+
RunRequest,
|
|
12
|
+
RunResults,
|
|
13
|
+
RunStatus,
|
|
14
|
+
project_from_dict,
|
|
15
|
+
project_to_dict,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .models import ProjectRevisionRow, ProjectRow, RunRow
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_dt(value: str | None) -> datetime | None:
|
|
22
|
+
"""Parse an ISO timestamp into an aware datetime."""
|
|
23
|
+
if value is None:
|
|
24
|
+
return None
|
|
25
|
+
return datetime.fromisoformat(value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fmt_dt(value: datetime | None) -> str | None:
|
|
29
|
+
"""Format a datetime as ISO-8601."""
|
|
30
|
+
if value is None:
|
|
31
|
+
return None
|
|
32
|
+
if value.tzinfo is None:
|
|
33
|
+
value = value.replace(tzinfo=UTC)
|
|
34
|
+
return value.isoformat()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def project_to_row(project: Project, revision: str) -> ProjectRow:
|
|
38
|
+
"""Build a current-project ORM row."""
|
|
39
|
+
now = datetime.now(UTC)
|
|
40
|
+
return ProjectRow(
|
|
41
|
+
id=project.id,
|
|
42
|
+
revision=revision,
|
|
43
|
+
name=project.name,
|
|
44
|
+
schema_version=project.schema_version,
|
|
45
|
+
body_json=json.dumps(project_to_dict(project), ensure_ascii=False),
|
|
46
|
+
created_at=now,
|
|
47
|
+
updated_at=now,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def row_to_project(row: ProjectRow) -> Project:
|
|
52
|
+
"""Restore a project from a current-project row."""
|
|
53
|
+
return project_from_dict(json.loads(row.body_json))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def revision_to_row(
|
|
57
|
+
project: Project,
|
|
58
|
+
revision: str,
|
|
59
|
+
compiled_json: str | None = None,
|
|
60
|
+
) -> ProjectRevisionRow:
|
|
61
|
+
"""Build a revision snapshot row."""
|
|
62
|
+
return ProjectRevisionRow(
|
|
63
|
+
project_id=project.id,
|
|
64
|
+
revision=revision,
|
|
65
|
+
body_json=json.dumps(project_to_dict(project), ensure_ascii=False),
|
|
66
|
+
compiled_json=compiled_json,
|
|
67
|
+
created_at=datetime.now(UTC),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_to_row(record: RunRecord) -> RunRow:
|
|
72
|
+
"""Build a run ORM row from a domain record."""
|
|
73
|
+
return RunRow(
|
|
74
|
+
run_id=record.run_id,
|
|
75
|
+
project_id=record.project_id,
|
|
76
|
+
project_revision=record.project_revision,
|
|
77
|
+
idempotency_key=record.idempotency_key,
|
|
78
|
+
status=record.status.value,
|
|
79
|
+
request_json=json.dumps(
|
|
80
|
+
record.request.model_dump(
|
|
81
|
+
mode="json", by_alias=True, exclude_none=True
|
|
82
|
+
),
|
|
83
|
+
ensure_ascii=False,
|
|
84
|
+
),
|
|
85
|
+
results_json=(
|
|
86
|
+
None
|
|
87
|
+
if record.results is None
|
|
88
|
+
else json.dumps(
|
|
89
|
+
record.results.model_dump(
|
|
90
|
+
mode="json", by_alias=True, exclude_none=True
|
|
91
|
+
),
|
|
92
|
+
ensure_ascii=False,
|
|
93
|
+
)
|
|
94
|
+
),
|
|
95
|
+
diff_json=(
|
|
96
|
+
None
|
|
97
|
+
if record.diff is None
|
|
98
|
+
else json.dumps(
|
|
99
|
+
record.diff.model_dump(
|
|
100
|
+
mode="json", by_alias=True, exclude_none=True
|
|
101
|
+
),
|
|
102
|
+
ensure_ascii=False,
|
|
103
|
+
)
|
|
104
|
+
),
|
|
105
|
+
attempt=record.attempt,
|
|
106
|
+
engine_version=record.engine_version,
|
|
107
|
+
plugin_versions_json=json.dumps(record.plugin_versions),
|
|
108
|
+
compiled_digest=record.compiled_digest,
|
|
109
|
+
error_json=(
|
|
110
|
+
None
|
|
111
|
+
if record.error is None
|
|
112
|
+
else json.dumps(
|
|
113
|
+
record.error.model_dump(
|
|
114
|
+
mode="json", by_alias=True, exclude_none=True
|
|
115
|
+
),
|
|
116
|
+
ensure_ascii=False,
|
|
117
|
+
)
|
|
118
|
+
),
|
|
119
|
+
request_digest=record.request_digest,
|
|
120
|
+
retry_policy_json=json.dumps(record.retry_policy_snapshot),
|
|
121
|
+
created_at=_parse_dt(record.created_at) or datetime.now(UTC),
|
|
122
|
+
started_at=_parse_dt(record.started_at),
|
|
123
|
+
finished_at=_parse_dt(record.finished_at),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def row_to_run(row: RunRow) -> RunRecord:
|
|
128
|
+
"""Restore a domain run record from an ORM row."""
|
|
129
|
+
results = None
|
|
130
|
+
if row.results_json:
|
|
131
|
+
results = RunResults.model_validate(json.loads(row.results_json))
|
|
132
|
+
diff = None
|
|
133
|
+
if row.diff_json:
|
|
134
|
+
diff = RunDiff.model_validate(json.loads(row.diff_json))
|
|
135
|
+
error = None
|
|
136
|
+
if row.error_json:
|
|
137
|
+
error = RunError.model_validate(json.loads(row.error_json))
|
|
138
|
+
return RunRecord(
|
|
139
|
+
runId=row.run_id,
|
|
140
|
+
idempotencyKey=row.idempotency_key,
|
|
141
|
+
projectId=row.project_id,
|
|
142
|
+
projectRevision=row.project_revision,
|
|
143
|
+
compiledDigest=row.compiled_digest,
|
|
144
|
+
status=RunStatus(row.status),
|
|
145
|
+
request=RunRequest.model_validate(json.loads(row.request_json)),
|
|
146
|
+
results=results,
|
|
147
|
+
diff=diff,
|
|
148
|
+
attempt=row.attempt,
|
|
149
|
+
retryPolicySnapshot=json.loads(row.retry_policy_json),
|
|
150
|
+
engineVersion=row.engine_version,
|
|
151
|
+
pluginVersions=json.loads(row.plugin_versions_json),
|
|
152
|
+
error=error,
|
|
153
|
+
createdAt=_fmt_dt(row.created_at) or "",
|
|
154
|
+
startedAt=_fmt_dt(row.started_at),
|
|
155
|
+
finishedAt=_fmt_dt(row.finished_at),
|
|
156
|
+
requestDigest=row.request_digest,
|
|
157
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Internal storage schema migrator."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Engine, select
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from .models import SchemaMetaRow
|
|
7
|
+
from .session import create_all
|
|
8
|
+
|
|
9
|
+
SCHEMA_VERSION = 1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def current_version(engine: Engine) -> int:
|
|
13
|
+
"""Return the installed schema version, or 0 when unset."""
|
|
14
|
+
create_all(engine)
|
|
15
|
+
with Session(engine) as session:
|
|
16
|
+
row = session.scalar(select(SchemaMetaRow).limit(1))
|
|
17
|
+
return 0 if row is None else row.version
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def upgrade(engine: Engine) -> None:
|
|
21
|
+
"""Create tables and stamp the schema version."""
|
|
22
|
+
create_all(engine)
|
|
23
|
+
with Session(engine) as session:
|
|
24
|
+
row = session.scalar(select(SchemaMetaRow).limit(1))
|
|
25
|
+
if row is None:
|
|
26
|
+
session.add(SchemaMetaRow(id=1, version=SCHEMA_VERSION))
|
|
27
|
+
else:
|
|
28
|
+
row.version = SCHEMA_VERSION
|
|
29
|
+
session.commit()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""ORM storage shapes (not domain models)."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint
|
|
6
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Base(DeclarativeBase):
|
|
10
|
+
"""Declarative base for Roborean storage tables."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SchemaMetaRow(Base):
|
|
14
|
+
"""Tracks the storage schema version."""
|
|
15
|
+
|
|
16
|
+
__tablename__ = "schema_meta"
|
|
17
|
+
|
|
18
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
19
|
+
version: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProjectRow(Base):
|
|
23
|
+
"""Current project document storage."""
|
|
24
|
+
|
|
25
|
+
__tablename__ = "projects"
|
|
26
|
+
|
|
27
|
+
id: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
28
|
+
revision: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
29
|
+
name: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
30
|
+
schema_version: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
31
|
+
body_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
32
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
33
|
+
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProjectRevisionRow(Base):
|
|
37
|
+
"""Immutable project revision snapshots."""
|
|
38
|
+
|
|
39
|
+
__tablename__ = "project_revisions"
|
|
40
|
+
|
|
41
|
+
project_id: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
42
|
+
revision: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
43
|
+
body_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
44
|
+
compiled_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
45
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RunRow(Base):
|
|
49
|
+
"""Durable run records."""
|
|
50
|
+
|
|
51
|
+
__tablename__ = "runs"
|
|
52
|
+
__table_args__ = (
|
|
53
|
+
UniqueConstraint(
|
|
54
|
+
"project_id",
|
|
55
|
+
"idempotency_key",
|
|
56
|
+
name="uq_runs_project_idempotency",
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
run_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
|
61
|
+
project_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
62
|
+
project_revision: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
63
|
+
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
64
|
+
status: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
65
|
+
request_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
66
|
+
results_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
67
|
+
diff_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
68
|
+
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
|
69
|
+
engine_version: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
70
|
+
plugin_versions_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
71
|
+
compiled_digest: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
72
|
+
error_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
73
|
+
request_digest: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
74
|
+
retry_policy_json: Mapped[str] = mapped_column(Text, nullable=False)
|
|
75
|
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
|
76
|
+
started_at: Mapped[datetime | None] = mapped_column(
|
|
77
|
+
DateTime(timezone=True), nullable=True
|
|
78
|
+
)
|
|
79
|
+
finished_at: Mapped[datetime | None] = mapped_column(
|
|
80
|
+
DateTime(timezone=True), nullable=True
|
|
81
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""SQLAlchemy project repository."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
|
|
6
|
+
from roborean_spec import Project, project_from_dict
|
|
7
|
+
from roborean_storage_base import NotFoundError
|
|
8
|
+
from sqlalchemy import select
|
|
9
|
+
from sqlalchemy.orm import sessionmaker
|
|
10
|
+
|
|
11
|
+
from .mappers import project_to_row, revision_to_row, row_to_project
|
|
12
|
+
from .models import ProjectRevisionRow, ProjectRow
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SqlAlchemyProjectRepository:
|
|
16
|
+
"""Persist projects through SQLAlchemy sessions."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, session_factory: sessionmaker) -> None:
|
|
19
|
+
"""Bind to a session factory."""
|
|
20
|
+
self._session_factory = session_factory
|
|
21
|
+
|
|
22
|
+
def get(self, project_id: str) -> Project:
|
|
23
|
+
"""Return the current project document."""
|
|
24
|
+
with self._session_factory() as session:
|
|
25
|
+
row = session.get(ProjectRow, project_id)
|
|
26
|
+
if row is None:
|
|
27
|
+
raise NotFoundError(project_id)
|
|
28
|
+
return row_to_project(row)
|
|
29
|
+
|
|
30
|
+
def get_revision(self, project_id: str, revision: str) -> Project:
|
|
31
|
+
"""Return a pinned revision document."""
|
|
32
|
+
with self._session_factory() as session:
|
|
33
|
+
row = session.get(
|
|
34
|
+
ProjectRevisionRow,
|
|
35
|
+
{"project_id": project_id, "revision": revision},
|
|
36
|
+
)
|
|
37
|
+
if row is None:
|
|
38
|
+
raise NotFoundError(f"{project_id}@{revision}")
|
|
39
|
+
return project_from_dict(json.loads(row.body_json))
|
|
40
|
+
|
|
41
|
+
def save(self, project: Project, *, revision: str | None = None) -> str:
|
|
42
|
+
"""Upsert the current project and write a revision snapshot."""
|
|
43
|
+
rev = revision or "1"
|
|
44
|
+
with self._session_factory() as session:
|
|
45
|
+
existing = session.get(ProjectRow, project.id)
|
|
46
|
+
if existing is None:
|
|
47
|
+
session.add(project_to_row(project, rev))
|
|
48
|
+
else:
|
|
49
|
+
existing.revision = rev
|
|
50
|
+
existing.name = project.name
|
|
51
|
+
existing.schema_version = project.schema_version
|
|
52
|
+
existing.body_json = project_to_row(project, rev).body_json
|
|
53
|
+
existing.updated_at = datetime.now(UTC)
|
|
54
|
+
session.merge(revision_to_row(project, rev))
|
|
55
|
+
session.commit()
|
|
56
|
+
return rev
|
|
57
|
+
|
|
58
|
+
def list_ids(self) -> list[str]:
|
|
59
|
+
"""List stored project identifiers."""
|
|
60
|
+
with self._session_factory() as session:
|
|
61
|
+
rows = session.scalars(
|
|
62
|
+
select(ProjectRow.id).order_by(ProjectRow.id)
|
|
63
|
+
)
|
|
64
|
+
return list(rows)
|
|
65
|
+
|
|
66
|
+
def delete(self, project_id: str) -> None:
|
|
67
|
+
"""Delete a project and its revisions."""
|
|
68
|
+
with self._session_factory() as session:
|
|
69
|
+
row = session.get(ProjectRow, project_id)
|
|
70
|
+
if row is None:
|
|
71
|
+
raise NotFoundError(project_id)
|
|
72
|
+
session.delete(row)
|
|
73
|
+
revisions = session.scalars(
|
|
74
|
+
select(ProjectRevisionRow).where(
|
|
75
|
+
ProjectRevisionRow.project_id == project_id
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
for revision in revisions:
|
|
79
|
+
session.delete(revision)
|
|
80
|
+
session.commit()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""SQLAlchemy run repository."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from roborean_spec import RunRecord
|
|
6
|
+
from roborean_storage_base import ConflictError, NotFoundError
|
|
7
|
+
from sqlalchemy import select
|
|
8
|
+
from sqlalchemy.exc import IntegrityError as SAIntegrityError
|
|
9
|
+
from sqlalchemy.orm import sessionmaker
|
|
10
|
+
|
|
11
|
+
from .mappers import row_to_run, run_to_row
|
|
12
|
+
from .models import RunRow
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SqlAlchemyRunRepository:
|
|
18
|
+
"""Persist durable runs through SQLAlchemy sessions."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, session_factory: sessionmaker) -> None:
|
|
21
|
+
"""Bind to a session factory."""
|
|
22
|
+
self._session_factory = session_factory
|
|
23
|
+
|
|
24
|
+
def get(self, run_id: str) -> RunRecord:
|
|
25
|
+
"""Return one run by identifier."""
|
|
26
|
+
with self._session_factory() as session:
|
|
27
|
+
row = session.get(RunRow, run_id)
|
|
28
|
+
if row is None:
|
|
29
|
+
raise NotFoundError(run_id)
|
|
30
|
+
return row_to_run(row)
|
|
31
|
+
|
|
32
|
+
def get_by_idempotency(
|
|
33
|
+
self, project_id: str, idempotency_key: str
|
|
34
|
+
) -> RunRecord | None:
|
|
35
|
+
"""Return an existing run for an idempotency key, if any."""
|
|
36
|
+
with self._session_factory() as session:
|
|
37
|
+
row = session.scalar(
|
|
38
|
+
select(RunRow).where(
|
|
39
|
+
RunRow.project_id == project_id,
|
|
40
|
+
RunRow.idempotency_key == idempotency_key,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
return None if row is None else row_to_run(row)
|
|
44
|
+
|
|
45
|
+
def save(self, record: RunRecord) -> None:
|
|
46
|
+
"""Insert a new run, mapping unique violations to conflicts."""
|
|
47
|
+
with self._session_factory() as session:
|
|
48
|
+
existing = session.scalar(
|
|
49
|
+
select(RunRow).where(
|
|
50
|
+
RunRow.project_id == record.project_id,
|
|
51
|
+
RunRow.idempotency_key == record.idempotency_key,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
if existing is not None:
|
|
55
|
+
if existing.request_digest != record.request_digest:
|
|
56
|
+
raise ConflictError(
|
|
57
|
+
"idempotency key reused with a different request body"
|
|
58
|
+
)
|
|
59
|
+
raise ConflictError("idempotency key already exists")
|
|
60
|
+
session.add(run_to_row(record))
|
|
61
|
+
try:
|
|
62
|
+
session.commit()
|
|
63
|
+
except SAIntegrityError as error:
|
|
64
|
+
session.rollback()
|
|
65
|
+
logger.debug(
|
|
66
|
+
"Unique constraint on run insert for %s",
|
|
67
|
+
record.run_id,
|
|
68
|
+
exc_info=True,
|
|
69
|
+
)
|
|
70
|
+
raise ConflictError("idempotency key already exists") from error
|
|
71
|
+
|
|
72
|
+
def update(self, record: RunRecord) -> None:
|
|
73
|
+
"""Replace an existing run row."""
|
|
74
|
+
with self._session_factory() as session:
|
|
75
|
+
row = session.get(RunRow, record.run_id)
|
|
76
|
+
if row is None:
|
|
77
|
+
raise NotFoundError(record.run_id)
|
|
78
|
+
mapped = run_to_row(record)
|
|
79
|
+
row.status = mapped.status
|
|
80
|
+
row.request_json = mapped.request_json
|
|
81
|
+
row.results_json = mapped.results_json
|
|
82
|
+
row.diff_json = mapped.diff_json
|
|
83
|
+
row.attempt = mapped.attempt
|
|
84
|
+
row.error_json = mapped.error_json
|
|
85
|
+
row.compiled_digest = mapped.compiled_digest
|
|
86
|
+
row.plugin_versions_json = mapped.plugin_versions_json
|
|
87
|
+
row.retry_policy_json = mapped.retry_policy_json
|
|
88
|
+
row.started_at = mapped.started_at
|
|
89
|
+
row.finished_at = mapped.finished_at
|
|
90
|
+
session.commit()
|
|
91
|
+
|
|
92
|
+
def list_for_project(
|
|
93
|
+
self, project_id: str, *, limit: int = 50
|
|
94
|
+
) -> list[RunRecord]:
|
|
95
|
+
"""List recent runs for a project, newest first."""
|
|
96
|
+
with self._session_factory() as session:
|
|
97
|
+
rows = session.scalars(
|
|
98
|
+
select(RunRow)
|
|
99
|
+
.where(RunRow.project_id == project_id)
|
|
100
|
+
.order_by(RunRow.created_at.desc())
|
|
101
|
+
.limit(limit)
|
|
102
|
+
)
|
|
103
|
+
return [row_to_run(row) for row in rows]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Engine and session helpers."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Engine, create_engine
|
|
4
|
+
from sqlalchemy.orm import sessionmaker
|
|
5
|
+
|
|
6
|
+
from .models import Base
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_engine(url: str) -> Engine:
|
|
10
|
+
"""Create a SQLAlchemy engine for the given URL."""
|
|
11
|
+
return create_engine(url, future=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_session_factory(engine: Engine) -> sessionmaker:
|
|
15
|
+
"""Create a session factory bound to ``engine``."""
|
|
16
|
+
return sessionmaker(bind=engine, expire_on_commit=False, future=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_all(engine: Engine) -> None:
|
|
20
|
+
"""Create ORM tables if they do not exist."""
|
|
21
|
+
Base.metadata.create_all(engine)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: roborean-storage-sqlalchemy
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: SQLAlchemy persistence adapter 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: roborean-spec>=0.1.1
|
|
10
|
+
Requires-Dist: roborean-storage-base>=0.1.1
|
|
11
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
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-sqlalchemy
|
|
20
|
+
|
|
21
|
+
SQLAlchemy adapter for Roborean projects and durable runs. ORM rows map
|
|
22
|
+
to/from Pydantic domain models; the engine depends only on repository ports.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from roborean_storage_sqlalchemy import (
|
|
26
|
+
SqlAlchemyProjectRepository,
|
|
27
|
+
make_engine,
|
|
28
|
+
make_session_factory,
|
|
29
|
+
upgrade,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
engine = make_engine("sqlite+pysqlite:///./roborean.db")
|
|
33
|
+
upgrade(engine)
|
|
34
|
+
factory = make_session_factory(engine)
|
|
35
|
+
projects = SqlAlchemyProjectRepository(factory)
|
|
36
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
roborean_storage_sqlalchemy/__init__.py,sha256=cZzDiSko-TZjfsvByOUuZqWWV7SC6tQ23MP5Tx6U-aU,470
|
|
2
|
+
roborean_storage_sqlalchemy/mappers.py,sha256=FeqYyNSqitkTnMOQ7d6cDt4To-W_wBpIkkfSq7Mv1bw,4830
|
|
3
|
+
roborean_storage_sqlalchemy/migrate.py,sha256=nvJ_JzauDkwUjdJJ8gSAfIQ-wMYJJxSa8aKFiWAiqkQ,868
|
|
4
|
+
roborean_storage_sqlalchemy/models.py,sha256=ttzPDYMiXRJz776e-HrRcb2gBWNzDHjb9M4uHn8x2Bk,3285
|
|
5
|
+
roborean_storage_sqlalchemy/project_repo.py,sha256=_HEbd7x3d2q3jEdsFy60nIYs-z9LlMA4iUpuXQLAi2c,3079
|
|
6
|
+
roborean_storage_sqlalchemy/run_repo.py,sha256=QBJ5owM87ikJYJ07IQwqpe5fogU190dovRBfB8YHZlM,3979
|
|
7
|
+
roborean_storage_sqlalchemy/session.py,sha256=TpE43L9gM5hQmFRttUFtf28kCy6nQhSXsDyzjNvu9-k,603
|
|
8
|
+
roborean_storage_sqlalchemy-0.1.2.dist-info/METADATA,sha256=W2u0bdBX2g6anc_BWiqp39FaZYBzUWP59YssuzUzRuA,1120
|
|
9
|
+
roborean_storage_sqlalchemy-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
roborean_storage_sqlalchemy-0.1.2.dist-info/RECORD,,
|