From 61f03c833a3e6c19349f3b699b09d9f4918f1847 Mon Sep 17 00:00:00 2001 From: Charlie Scherer Date: Thu, 11 Jun 2026 23:09:26 -0500 Subject: [PATCH] feat(persistence): Store interface + SQLAlchemy adapter The persistence interface is `Store` (not `Repository`, which collided with the `Repo` domain object). A SQLAlchemy-backed `SqlAlchemyStore` replaces the hand-rolled sqlite3 and dict-based adapters; "in-memory" is just an in-memory SQLite engine (StaticPool), with on-disk SQLite as the other tested backend. - Translators, not ORM-bound core: the pure, frozen domain models stay untouched; the adapter's mutable row classes own to_domain/from_domain, and parent->child links are ORM relationships (selectin eager-load, cascade insert). - Integrity enforced by the Store base via template methods: create_task/save_task run validate_task_consistency / validate_history_append_only and delegate raw persistence to _insert_task / _stored_history / _update_task, so an adapter can't skip the checks. _update_task only appends new entries and fulfils the current entry's promises in place. - Reflective tests guard that rows and domain dataclasses can't drift. Schema via metadata.create_all; Alembic deferred (BACKLOG). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/BACKLOG.md | 3 + pyproject.toml | 4 +- src/panopticon/core/store.py | 167 +++++++++ src/panopticon/taskservice/__init__.py | 5 + .../taskservice/store_sqlalchemy.py | 267 +++++++++++++ tests/test_store.py | 350 ++++++++++++++++++ uv.lock | 115 ++++++ 7 files changed, 910 insertions(+), 1 deletion(-) create mode 100644 src/panopticon/core/store.py create mode 100644 src/panopticon/taskservice/__init__.py create mode 100644 src/panopticon/taskservice/store_sqlalchemy.py create mode 100644 tests/test_store.py diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 0c7a0195..c152208b 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -23,6 +23,9 @@ in the ADRs; this file is for the smaller stuff that doesn't have a home there y (`USER`/`AGENT`) is queryable metadata; the engine doesn't yet use it to decide who may trigger a transition, nor is there a per-*transition* auto-advance flag. Wire it when the agent runtime needs it (around the parity workflow, Slice 4). _(Slice 1, P2)_ +- [ ] **No schema migrations** — the SQLAlchemy adapter creates tables with + `metadata.create_all`; there's no versioning/upgrade path. Add Alembic (or equivalent) + before the schema ships anywhere with data to preserve. _(Slice 1, P2)_ ## Deferred features (not yet scheduled, or scheduled but flagged here) diff --git a/pyproject.toml b/pyproject.toml index af6ab3d7..33423fb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,9 @@ name = "panopticon" version = "0.0.1" description = "Orchestrate multiple coding agents across isolated tasks and configurable workflows." requires-python = ">=3.11" -dependencies = [] +dependencies = [ + "sqlalchemy>=2.0.50", +] [dependency-groups] dev = [ diff --git a/src/panopticon/core/store.py b/src/panopticon/core/store.py new file mode 100644 index 00000000..3df0b9cd --- /dev/null +++ b/src/panopticon/core/store.py @@ -0,0 +1,167 @@ +"""The store interface: the abstraction over persisted task state. + +A backend-agnostic interface (ADR 0001/0006). The task service is its sole owner and the +single writer; a SQLAlchemy adapter implements it (SQLite — in-memory or on-disk — in this +slice; other SQL backends later). + +Integrity rules — the "transition enforcement at the boundary": + +* a task's history is non-empty and ``state`` equals the last entry's ``to_state`` + (``validate_task_consistency``), checked on create *and* save; +* on save, history is **append-only**: the stored history is a prefix of the supplied one and + recorded transition facts never change (``validate_history_append_only``). + +These are *enforced by the base class*: every public method delegates to a ``_``-prefixed +primitive an adapter implements, and ``create_task`` / ``save_task`` run the checks before +delegating to ``_create_task`` / ``_stored_history`` / ``_update_task`` — so no adapter can +persist without the checks running. + +The *legality* of a transition (which state may follow which) is decided by the engine +before save; the store guarantees the persisted record stays internally consistent. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence + +from panopticon.core.models import HistoryEntry, Repo, Task + + +class StoreError(Exception): + """Base class for store failures.""" + + +class NotFound(StoreError): + """Raised when an entity referenced by id does not exist.""" + + +class AlreadyExists(StoreError): + """Raised when creating an entity whose id is already taken.""" + + +class IntegrityError(StoreError): + """Raised when a write would violate an integrity rule (e.g. non-append-only history).""" + + +class Store(ABC): + """Persistence boundary for repos and tasks. + + The public methods are concrete and **delegate to the ``_``-prefixed primitives** an + adapter implements — so every overridable method is underscored and cross-cutting rules + live in one place. ``create_task`` / ``save_task`` additionally run the integrity checks + (``validate_task_consistency`` / ``validate_history_append_only``) before delegating, so an + adapter can't skip them. + """ + + # -- repos (public façade) ---------------------------------------------------- + + def create_repo(self, repo: Repo) -> None: + """Persist a new repo. Raises :class:`AlreadyExists` if its id is taken.""" + self._create_repo(repo) + + def get_repo(self, repo_id: str) -> Repo | None: + """Return the repo, or ``None`` if it does not exist.""" + return self._get_repo(repo_id) + + def list_repos(self) -> list[Repo]: + """Return all repos.""" + return self._list_repos() + + # -- tasks (public façade; create/save also enforce the integrity rules) ------ + + def create_task(self, task: Task) -> None: + """Persist a new task and its initial history, after checking consistency.""" + validate_task_consistency(task) + self._create_task(task) + + def get_task(self, task_id: str) -> Task | None: + """Return the task (with full history), or ``None`` if it does not exist.""" + return self._get_task(task_id) + + def list_tasks(self) -> list[Task]: + """Return all tasks (with full history).""" + return self._list_tasks() + + def save_task(self, task: Task) -> None: + """Persist an updated task, enforcing consistency and append-only history.""" + validate_task_consistency(task) + stored = self._stored_history(task.id) + validate_history_append_only(stored, task.history) + self._update_task(task, stored) + + # -- persistence primitives (adapters implement these) ----------------------- + + @abstractmethod + def _create_repo(self, repo: Repo) -> None: + """Insert a new repo. Raise :class:`AlreadyExists` if its id is taken.""" + + @abstractmethod + def _get_repo(self, repo_id: str) -> Repo | None: + """Return the repo, or ``None``.""" + + @abstractmethod + def _list_repos(self) -> list[Repo]: + """Return all repos.""" + + @abstractmethod + def _create_task(self, task: Task) -> None: + """Insert a new task + its history. Raise :class:`AlreadyExists` if the id is taken, + :class:`NotFound` if its ``repo_id`` does not exist.""" + + @abstractmethod + def _get_task(self, task_id: str) -> Task | None: + """Return the task (with full history), or ``None``.""" + + @abstractmethod + def _list_tasks(self) -> list[Task]: + """Return all tasks (with full history).""" + + @abstractmethod + def _stored_history(self, task_id: str) -> list[HistoryEntry]: + """Return the task's persisted history. Raise :class:`NotFound` if it does not exist.""" + + @abstractmethod + def _update_task(self, task: Task, stored: Sequence[HistoryEntry]) -> None: + """Persist scalar changes, fulfil the current entry's promises, and append new entries + (``stored`` is the already-validated persisted history).""" + + +# -- Shared integrity checks (adapters call these so the rules live in one place) -------- + + +def validate_task_consistency(task: Task) -> None: + """Check a task is internally consistent: non-empty history, state matches its tail.""" + if not task.history: + raise IntegrityError(f"task {task.id!r} has empty history") + if task.state != task.history[-1].to_state: + raise IntegrityError( + f"task {task.id!r}: state {task.state!r} != last history to_state " + f"{task.history[-1].to_state!r}" + ) + + +def _transition_facts(entry: HistoryEntry) -> tuple[str, str | None, str, str | None, str | None]: + """An entry's transition facts — everything that is immutable once recorded.""" + return (entry.at, entry.from_state, entry.to_state, entry.trigger, entry.note) + + +def validate_history_append_only( + stored: Sequence[HistoryEntry], incoming: Sequence[HistoryEntry] +) -> None: + """Check ``incoming`` only extends ``stored``. + + Transition facts are immutable for every recorded entry. The sole permitted in-place + change is the **current (last) entry's responsibilities**, which the agent fulfils over + the course of that turn (the promise-on-entry model); once an entry is followed by another + it is frozen. + """ + if len(incoming) < len(stored): + raise IntegrityError("history shrank (not append-only)") + for i, prev in enumerate(stored): + cur = incoming[i] + if _transition_facts(prev) != _transition_facts(cur): + raise IntegrityError("existing history was modified (not append-only)") + # Only the current entry's promises may still change; earlier entries are final. + if i < len(stored) - 1 and list(prev.responsibilities) != list(cur.responsibilities): + raise IntegrityError("a finalized entry's responsibilities were modified") diff --git a/src/panopticon/taskservice/__init__.py b/src/panopticon/taskservice/__init__.py new file mode 100644 index 00000000..9b8819b9 --- /dev/null +++ b/src/panopticon/taskservice/__init__.py @@ -0,0 +1,5 @@ +"""The task service: the deterministic control plane. + +Owns the store (the sole DB authority, ADR 0006), hosts the workflow registry, and +drives task lifecycle. This package must remain LLM-free (the determinism invariant). +""" diff --git a/src/panopticon/taskservice/store_sqlalchemy.py b/src/panopticon/taskservice/store_sqlalchemy.py new file mode 100644 index 00000000..088e5d81 --- /dev/null +++ b/src/panopticon/taskservice/store_sqlalchemy.py @@ -0,0 +1,267 @@ +"""SQLAlchemy store adapter (ADR 0001/0006: SQL behind the backend-agnostic interface). + +One adapter serves every SQL backend SQLAlchemy speaks; **"in-memory" is just an in-memory +SQLite engine**. The pure, frozen domain models (:mod:`panopticon.core.models`) never touch +the ORM — this adapter owns mutable *row* classes and each knows how to translate itself +``to_domain`` / ``from_domain``. Parent→child links are ORM ``relationship``\\ s (loaded +eagerly via ``selectin``), so reading a task pulls in its history and responsibilities and +writing one cascades — no hand-written load/insert code. + +The integrity checks are enforced by the base :class:`~panopticon.core.store.Store` +template methods; this adapter implements the persistence primitives. ``_update_task`` only +appends new entries and updates the current entry's promises in place — never rewriting +recorded history — rather than letting the unit-of-work write whatever is dirty. Schema via +``metadata.create_all``; Alembic deferred — see docs/BACKLOG.md. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from sqlalchemy import ForeignKey, ForeignKeyConstraint, create_engine, select +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + Session, + mapped_column, + relationship, + sessionmaker, +) +from sqlalchemy.pool import StaticPool + +from panopticon.core.models import Actor, HistoryEntry, Repo, Responsibility, Status, Task +from panopticon.core.store import ( + AlreadyExists, + IntegrityError, + NotFound, + Store, +) + +_IN_MEMORY = ("sqlite://", "sqlite:///:memory:") + + +# -- ORM row classes (mutable; live only in this adapter; own their translation) ---- + + +class _Base(DeclarativeBase): + pass + + +class _RepoRow(_Base): + __tablename__ = "repo" + + id: Mapped[str] = mapped_column(primary_key=True) + name: Mapped[str] + git_url: Mapped[str] + default_base: Mapped[str] + + def to_domain(self) -> Repo: + return Repo(id=self.id, name=self.name, git_url=self.git_url, default_base=self.default_base) + + @classmethod + def from_domain(cls, repo: Repo) -> _RepoRow: + return cls(id=repo.id, name=repo.name, git_url=repo.git_url, default_base=repo.default_base) + + +class _TaskRow(_Base): + __tablename__ = "task" + + id: Mapped[str] = mapped_column(primary_key=True) + repo_id: Mapped[str] = mapped_column(ForeignKey("repo.id")) + workflow: Mapped[str] + state: Mapped[str] + turn: Mapped[str] + slug: Mapped[str | None] + history: Mapped[list[_HistoryRow]] = relationship( + order_by="_HistoryRow.seq", + cascade="all, delete-orphan", + lazy="selectin", + back_populates="task", + ) + + def to_domain(self) -> Task: + return Task( + id=self.id, + repo_id=self.repo_id, + workflow=self.workflow, + state=self.state, + turn=Actor(self.turn), + slug=self.slug, + history=[h.to_domain() for h in self.history], + ) + + @classmethod + def from_domain(cls, task: Task) -> _TaskRow: + return cls( + id=task.id, + repo_id=task.repo_id, + workflow=task.workflow, + state=task.state, + turn=task.turn.value, + slug=task.slug, + history=[_HistoryRow.from_domain(e, seq) for seq, e in enumerate(task.history)], + ) + + +class _HistoryRow(_Base): + __tablename__ = "history" + + task_id: Mapped[str] = mapped_column(ForeignKey("task.id"), primary_key=True) + seq: Mapped[int] = mapped_column(primary_key=True) + at: Mapped[str] + from_state: Mapped[str | None] + to_state: Mapped[str] + trigger: Mapped[str | None] + note: Mapped[str | None] + task: Mapped[_TaskRow] = relationship(back_populates="history") + responsibilities: Mapped[list[_ResponsibilityRow]] = relationship( + order_by="_ResponsibilityRow.idx", + cascade="all, delete-orphan", + lazy="selectin", + back_populates="history", + ) + + def to_domain(self) -> HistoryEntry: + return HistoryEntry( + at=self.at, + from_state=self.from_state, + to_state=self.to_state, + trigger=self.trigger, + note=self.note, + responsibilities=[r.to_domain() for r in self.responsibilities], + ) + + @classmethod + def from_domain(cls, entry: HistoryEntry, seq: int) -> _HistoryRow: + # FK columns (task_id, and the child rows' task_id/seq) are filled by the relationships. + return cls( + seq=seq, + at=entry.at, + from_state=entry.from_state, + to_state=entry.to_state, + trigger=entry.trigger, + note=entry.note, + responsibilities=[ + _ResponsibilityRow.from_domain(r, idx) + for idx, r in enumerate(entry.responsibilities) + ], + ) + + +class _ResponsibilityRow(_Base): + __tablename__ = "responsibility" + __table_args__ = ( + ForeignKeyConstraint(["task_id", "seq"], ["history.task_id", "history.seq"]), + ) + + task_id: Mapped[str] = mapped_column(primary_key=True) + seq: Mapped[int] = mapped_column(primary_key=True) + idx: Mapped[int] = mapped_column(primary_key=True) + key: Mapped[str] + description: Mapped[str] + status: Mapped[str] + comment: Mapped[str | None] + history: Mapped[_HistoryRow] = relationship(back_populates="responsibilities") + + def to_domain(self) -> Responsibility: + return Responsibility( + key=self.key, description=self.description, status=Status(self.status), comment=self.comment + ) + + @classmethod + def from_domain(cls, r: Responsibility, idx: int) -> _ResponsibilityRow: + return cls( + idx=idx, key=r.key, description=r.description, status=r.status.value, comment=r.comment + ) + + +def _fulfil_current_promises(history_row: _HistoryRow, entry: HistoryEntry) -> None: + """Apply in-place fulfilment of the current entry's promises (status/comment updates only). + + The promise model never changes an entry's responsibility *set* — only each promise's + status/comment — so this is a row-by-row update, no insert/delete. Guard the invariant + since :func:`~panopticon.core.store.validate_history_append_only` doesn't police the + current entry's responsibilities. + """ + existing = history_row.responsibilities # ordered by idx + if [r.key for r in existing] != [r.key for r in entry.responsibilities]: + raise IntegrityError("the current entry's responsibility set changed") + for row, r in zip(existing, entry.responsibilities): + row.status = r.status.value + row.comment = r.comment + + +class SqlAlchemyStore(Store): + """A :class:`~panopticon.core.store.Store` backed by SQLAlchemy.""" + + def __init__(self, url: str = "sqlite://") -> None: + if url in _IN_MEMORY: + # An in-memory SQLite DB lives only as long as its single connection — pin one. + self._engine = create_engine( + url, connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + else: + self._engine = create_engine(url) + _Base.metadata.create_all(self._engine) + self._session: sessionmaker[Session] = sessionmaker(self._engine) + + def close(self) -> None: + self._engine.dispose() + + # -- repos -------------------------------------------------------------------- + + def _create_repo(self, repo: Repo) -> None: + with self._session.begin() as s: + if s.get(_RepoRow, repo.id) is not None: + raise AlreadyExists(f"repo {repo.id!r} already exists") + s.add(_RepoRow.from_domain(repo)) + + def _get_repo(self, repo_id: str) -> Repo | None: + with self._session() as s: + row = s.get(_RepoRow, repo_id) + return row.to_domain() if row is not None else None + + def _list_repos(self) -> list[Repo]: + with self._session() as s: + return [r.to_domain() for r in s.scalars(select(_RepoRow).order_by(_RepoRow.id))] + + # -- tasks: reads + persistence primitives (the base's template methods drive these) -- + + def _get_task(self, task_id: str) -> Task | None: + with self._session() as s: + row = s.get(_TaskRow, task_id) + return row.to_domain() if row is not None else None + + def _list_tasks(self) -> list[Task]: + with self._session() as s: + return [r.to_domain() for r in s.scalars(select(_TaskRow).order_by(_TaskRow.id))] + + def _create_task(self, task: Task) -> None: + with self._session.begin() as s: + if s.get(_TaskRow, task.id) is not None: + raise AlreadyExists(f"task {task.id!r} already exists") + if s.get(_RepoRow, task.repo_id) is None: + raise NotFound(f"repo {task.repo_id!r} does not exist") + s.add(_TaskRow.from_domain(task)) # cascade inserts history + responsibilities + + def _stored_history(self, task_id: str) -> list[HistoryEntry]: + with self._session() as s: + row = s.get(_TaskRow, task_id) + if row is None: + raise NotFound(f"task {task_id!r} does not exist") + return [h.to_domain() for h in row.history] + + def _update_task(self, task: Task, stored: Sequence[HistoryEntry]) -> None: + with self._session.begin() as s: + row = s.get(_TaskRow, task.id) + if row is None: # defensive: single-writer, so it still exists after _stored_history + raise NotFound(f"task {task.id!r} does not exist") + row.state = task.state + row.turn = task.turn.value + row.slug = task.slug + # The current (last stored) entry's promises may have been fulfilled in place. + if stored: + _fulfil_current_promises(row.history[len(stored) - 1], task.history[len(stored) - 1]) + # Append any new entries; the relationship cascade inserts them and their children. + for seq in range(len(stored), len(task.history)): + row.history.append(_HistoryRow.from_domain(task.history[seq], seq)) diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 00000000..74462640 --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,350 @@ +"""Store contract tests — run against the SQLAlchemy adapter. + +Parametrizing over in-memory and on-disk SQLite engines exercises the same code path against +a real connection, and proves the integrity rules hold. The "domain / persistence sync" +section additionally guards (by reflection) that the ORM rows and the domain dataclasses can't +silently drift apart. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import MISSING, fields +from pathlib import Path +from typing import Any, get_origin, get_type_hints + +import pytest +from sqlalchemy import inspect + +from panopticon.core.models import Actor, HistoryEntry, Repo, Responsibility, Status, Task +from panopticon.core.store import ( + AlreadyExists, + IntegrityError, + NotFound, + Store, +) +from panopticon.taskservice import store_sqlalchemy as rs +from panopticon.taskservice.store_sqlalchemy import SqlAlchemyStore +from panopticon.workflows import Spike + +WF = Spike() + + +@pytest.fixture(params=["memory", "file"]) +def store(request: pytest.FixtureRequest, tmp_path: Path) -> Iterator[Store]: + # Both backends are the one SQLAlchemy adapter; "memory" is an in-memory SQLite engine, + # "file" is on-disk SQLite (so we also exercise persistence across a real connection). + if request.param == "memory": + r = SqlAlchemyStore("sqlite://") + else: + r = SqlAlchemyStore(f"sqlite:///{tmp_path / 'tasks.db'}") + yield r + r.close() + + +def _seed_repo(store: Store, repo_id: str = "r1") -> None: + store.create_repo(Repo(id=repo_id, name="acme/widgets", git_url="https://github.com/acme/widgets.git")) + + +def _new_task(store: Store, task_id: str = "t1", repo_id: str = "r1") -> Task: + task = WF.start_task(task_id, repo_id, at="t0") + store.create_task(task) + return task + + +# -- repos -------------------------------------------------------------------------- + + +def test_create_and_get_repo(store: Store) -> None: + _seed_repo(store) + got = store.get_repo("r1") + assert got is not None + assert got.name == "acme/widgets" + assert got.default_base == "main" + + +def test_get_missing_repo_returns_none(store: Store) -> None: + assert store.get_repo("nope") is None + + +def test_duplicate_repo_raises(store: Store) -> None: + _seed_repo(store) + with pytest.raises(AlreadyExists): + _seed_repo(store) + + +def test_list_repos(store: Store) -> None: + store.create_repo(Repo(id="r1", name="a", git_url="https://x/a.git")) + store.create_repo(Repo(id="r2", name="b", git_url="https://x/b.git")) + assert {r.id for r in store.list_repos()} == {"r1", "r2"} + + +# -- task creation ------------------------------------------------------------------ + + +def test_create_task_requires_existing_repo(store: Store) -> None: + task = WF.start_task("t1", "ghost", at="t0") + with pytest.raises(NotFound): + store.create_task(task) + + +def test_create_and_get_task_roundtrips(store: Store) -> None: + _seed_repo(store) + task = _new_task(store) + task.slug = "fix-widget" # not yet persisted + got = store.get_task("t1") + assert got is not None + assert got.state == "ITERATING" + assert got.turn is Actor.AGENT + assert got.workflow == "spike" + assert got.slug is None # create persisted before slug was set + assert [(h.from_state, h.to_state) for h in got.history] == [(None, "ITERATING")] + + +def test_duplicate_task_raises(store: Store) -> None: + _seed_repo(store) + _new_task(store) + with pytest.raises(AlreadyExists): + _new_task(store) + + +def test_create_task_rejects_inconsistent_state(store: Store) -> None: + _seed_repo(store) + bad = Task( + id="t1", + repo_id="r1", + workflow="spike", + state="COMPLETE", # disagrees with history tail + turn=Actor.AGENT, + history=[HistoryEntry(at="t0", from_state=None, to_state="ITERATING")], + ) + with pytest.raises(IntegrityError): + store.create_task(bad) + + +# -- saving / append-only ----------------------------------------------------------- + + +def test_save_persists_transition_and_slug(store: Store) -> None: + _seed_repo(store) + task = _new_task(store) + WF.apply_transition(task, "COMPLETE", at="t1", trigger="finish") + task.slug = "fix-widget" + store.save_task(task) + + got = store.get_task("t1") + assert got is not None + assert got.state == "COMPLETE" + assert got.turn is Actor.USER # COMPLETE is a terminal (foreground) state + assert got.slug == "fix-widget" + assert [h.to_state for h in got.history] == ["ITERATING", "COMPLETE"] + + +def test_save_missing_task_raises(store: Store) -> None: + _seed_repo(store) + task = WF.start_task("ghost", "r1", at="t0") + with pytest.raises(NotFound): + store.save_task(task) + + +def test_save_rejects_history_rewrite(store: Store) -> None: + _seed_repo(store) + _new_task(store) + # Same length as stored, but the (only) existing entry is altered. + tampered = Task( + id="t1", + repo_id="r1", + workflow="spike", + state="COMPLETE", + turn=Actor.AGENT, + history=[HistoryEntry(at="t0", from_state=None, to_state="COMPLETE")], + ) + with pytest.raises(IntegrityError): + store.save_task(tampered) + + +def test_save_rejects_history_shrink(store: Store) -> None: + _seed_repo(store) + task = _new_task(store) + WF.apply_transition(task, "COMPLETE", at="t1") + store.save_task(task) # stored history now length 2 + + truncated = Task( + id="t1", + repo_id="r1", + workflow="spike", + state="ITERATING", + turn=Actor.AGENT, + history=[HistoryEntry(at="t0", from_state=None, to_state="ITERATING")], + ) + with pytest.raises(IntegrityError): + store.save_task(truncated) + + +# -- isolation ---------------------------------------------------------------------- + + +def test_get_returns_independent_copy(store: Store) -> None: + _seed_repo(store) + _new_task(store) + got = store.get_task("t1") + assert got is not None + got.state = "COMPLETE" # mutating the returned object must not change storage + again = store.get_task("t1") + assert again is not None + assert again.state == "ITERATING" + + +def test_resolved_responsibilities_roundtrip(store: Store) -> None: + _seed_repo(store) + # Responsibilities live on the entry for the state that defines them (WORKING here). + resolved = [ + Responsibility(key="tests-pass", description="Tests pass", status=Status.MET), + Responsibility( + key="pr-opened", description="PR opened", status=Status.FAILED, comment="forge down" + ), + ] + task = Task( + id="t1", + repo_id="r1", + workflow="gated", + state="COMPLETE", + turn=Actor.AGENT, + history=[ + HistoryEntry(at="t0", from_state=None, to_state="WORKING", responsibilities=resolved), + HistoryEntry(at="t1", from_state="WORKING", to_state="COMPLETE"), + ], + ) + store.create_task(task) + + got = store.get_task("t1") + assert got is not None + assert got.history[0].responsibilities == resolved # order, status, and comment preserved + assert got.history[1].responsibilities == [] + + +def test_current_entry_responsibilities_persist_in_place(store: Store) -> None: + _seed_repo(store) + task = Task( + id="t1", + repo_id="r1", + workflow="gated", + state="WORKING", + turn=Actor.AGENT, + history=[ + HistoryEntry(at="t0", from_state=None, to_state="PLAN"), + HistoryEntry( + at="t1", + from_state="PLAN", + to_state="WORKING", + responsibilities=[Responsibility(key="tests-pass", description="Tests pass")], + ), + ], + ) + store.create_task(task) + + # Fulfil the promise on the current entry and save — the in-place change must persist. + task.record_responsibility(key="tests-pass", status=Status.MET) + store.save_task(task) + + got = store.get_task("t1") + assert got is not None + assert got.history[-1].responsibilities[0].status is Status.MET + + +# -- domain / persistence sync ------------------------------------------------------ +# +# Two reflective guards so the ORM rows and the domain dataclasses can't drift apart: +# 1. structural — every scalar domain field has a column (and no orphan columns); +# 2. behavioral — a fully-populated instance round-trips intact, and every field is +# actually exercised (so a new field can't slip through unpersisted). + +# domain class -> (row class, columns the table is allowed to add for persistence) +_SCHEMA: dict[type, tuple[type, set[str]]] = { + Repo: (rs._RepoRow, set()), + Responsibility: (rs._ResponsibilityRow, {"task_id", "seq", "idx"}), + HistoryEntry: (rs._HistoryRow, {"task_id", "seq"}), + Task: (rs._TaskRow, set()), +} + + +def _scalar_field_names(domain: type) -> set[str]: + """Domain field names that should map to a column — i.e. excluding nested list fields.""" + hints = get_type_hints(domain) + return {f.name for f in fields(domain) if get_origin(hints[f.name]) is not list} + + +@pytest.mark.parametrize("domain", list(_SCHEMA)) +def test_rows_and_domain_models_stay_in_sync(domain: type) -> None: + row, persistence_only = _SCHEMA[domain] + scalar = _scalar_field_names(domain) + columns = set(inspect(row).columns.keys()) + assert scalar <= columns, f"{domain.__name__}: domain fields with no column: {scalar - columns}" + assert columns <= scalar | persistence_only, ( + f"{row.__name__}: columns not backed by a domain field: {columns - scalar - persistence_only}" + ) + + +def _fully_populated_task() -> Task: + """A task touching every field of Task/HistoryEntry/Responsibility with a non-default value.""" + return Task( + id="t-full", + repo_id="r1", + workflow="gated", + state="WORKING", + turn=Actor.AGENT, + slug="fix-the-widget", + history=[ + HistoryEntry( + at="t0", from_state=None, to_state="PLAN", trigger="start", note="kickoff" + ), + HistoryEntry( + at="t1", + from_state="PLAN", + to_state="WORKING", + trigger="advance", + note="plan approved", + responsibilities=[ + Responsibility( + key="tests-pass", description="Tests pass", status=Status.MET, comment="green" + ), + Responsibility( + key="pr-opened", + description="PR opened", + status=Status.FAILED, + comment="forge down", + ), + ], + ), + ], + ) + + +def _assert_every_field_exercised(instances: list[Any], domain: type) -> None: + """Fail if any field of ``domain`` equals its default across *all* ``instances``. + + Forces the fixture to populate new fields with a real value, so the round-trip below + genuinely tests them (a field left at its default would silently go unchecked). + """ + for f in fields(domain): + if f.default is not MISSING: + default: object = f.default + elif f.default_factory is not MISSING: # type: ignore[misc] + default = f.default_factory() + else: + default = object() # required field: any provided value differs from this sentinel + if not any(getattr(i, f.name) != default for i in instances): + pytest.fail(f"{domain.__name__}.{f.name} is never exercised — extend _fully_populated_task") + + +def test_full_task_round_trips_and_exercises_every_field(store: Store) -> None: + _seed_repo(store) + task = _fully_populated_task() + entries = task.history + responsibilities = [r for e in entries for r in e.responsibilities] + _assert_every_field_exercised([task], Task) + _assert_every_field_exercised(entries, HistoryEntry) + _assert_every_field_exercised(responsibilities, Responsibility) + + store.create_task(task) + assert store.get_task(task.id) == task # every field survives the round trip (dataclass __eq__) diff --git a/uv.lock b/uv.lock index 96bdd94c..903eee7b 100644 --- a/uv.lock +++ b/uv.lock @@ -55,6 +55,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, + { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a9/a3c2fa886c5b94863fb0e61b3bc14610b7aa94cf4f17f8741b11708305fc/greenlet-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", size = 234989, upload-time = "2026-05-20T13:08:27.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -210,6 +273,9 @@ wheels = [ name = "panopticon" version = "0.0.1" source = { editable = "." } +dependencies = [ + { name = "sqlalchemy" }, +] [package.dev-dependencies] dev = [ @@ -218,6 +284,7 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "sqlalchemy", specifier = ">=2.0.50" }] [package.metadata.requires-dev] dev = [ @@ -268,6 +335,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5d/3172686af1770e4de2805f919a51441085f589ddadf3dd76ec582f84f497/sqlalchemy-2.0.50-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa6e403663a9c43c8fef7ce4bdb4cf48bcd8d352e91deda2a99f963270bd508", size = 2161366, upload-time = "2026-05-24T20:00:02.061Z" }, + { url = "https://files.pythonhosted.org/packages/0f/90/e98dedea3c3e663a17afcd003a34ba45efdac2cea3b6f2e4585e2b1e2537/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51b637a84f9fa35ae1f9017e786cb142974a25305085e1b378b3647a67f65ad3", size = 3318926, upload-time = "2026-05-24T20:07:42.369Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/501308c2babb62c11753ecb4ee88ba9eef019419a4d6cbf7cb13e2bad353/sqlalchemy-2.0.50-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2dab927761d9108550f0cf8e66ff21af56f907a0ce0a689793db615e2b55f62c", size = 3319199, upload-time = "2026-05-24T20:14:28.551Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/d88996c5e03ed6248c3a788d20f0b8d8b376b9f8a495e4bab9df7c72d2f8/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:545eae198d37bcf837a10ede3684e2af32458d6f35c597c35c2de7502dc38fc4", size = 3270301, upload-time = "2026-05-24T20:07:44.917Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/1ae0e65161b51cc43e5ca75430ef79d80e23b5042d645586c2c342c3b92e/sqlalchemy-2.0.50-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fec460e18cdbb4c7773531122ce9a27e96c6ca17af3933941d94da475ad2c86", size = 3293465, upload-time = "2026-05-24T20:14:30.501Z" }, + { url = "https://files.pythonhosted.org/packages/83/29/17c0003f2c0dfa6d1b97672475707e3ec5980db09defd7fa20beb6833bbd/sqlalchemy-2.0.50-cp311-cp311-win32.whl", hash = "sha256:e6e814658818fd165e749e3d8490ef16cc7f379a118c37ada8b0589ffbaaac22", size = 2120694, upload-time = "2026-05-24T20:08:09.237Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/280d00654cc19d1fccf236fa5070f6dd04b84dde6f1b2e637bde0ff340a7/sqlalchemy-2.0.50-cp311-cp311-win_amd64.whl", hash = "sha256:1c5f858fe79c9f5d8fda065c06186356acb7f8df3cd52dbd5ee3f200e4b144f5", size = 2145315, upload-time = "2026-05-24T20:08:10.952Z" }, + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"