refactor(cloud-store): adopt SQLAlchemy adapter
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
## 2. Repository Contract And Migrations
|
||||
|
||||
- [x] 2.1 Define the cloud repository contract for hosts, device snapshots, plugins, tasks, attempts, leases, reservations, and transactional assignment operations.
|
||||
- [ ] 2.2 Implement SQLAlchemy models and a repository adapter that preserves existing `CloudStore` observable behavior.
|
||||
- [x] 2.2 Implement SQLAlchemy models and a repository adapter that preserves existing `CloudStore` observable behavior.
|
||||
- [ ] 2.3 Add PostgreSQL and SQLite database URL support with engine/session lifecycle owned by the cloud application.
|
||||
- [ ] 2.4 Add Alembic configuration and a baseline migration that preserves existing host, device, task, and plugin data while adding lease/attempt/result fields.
|
||||
- [ ] 2.5 Add forward/downgrade migration tests and schema-version readiness checks.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Integer, String, Text
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class HostRow(Base):
|
||||
__tablename__ = "host_registrations"
|
||||
|
||||
host_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
address: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
last_seen_at: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
|
||||
class PooledDeviceRow(Base):
|
||||
__tablename__ = "pooled_devices"
|
||||
|
||||
host_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
device_id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
driver_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False)
|
||||
capability_tags_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
synced_at: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
|
||||
class ScheduledTaskRow(Base):
|
||||
__tablename__ = "scheduled_tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
goal: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
workflow_definition_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
constraints_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String, nullable=False)
|
||||
assigned_device_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
assigned_host_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
created_at: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
|
||||
class PluginRow(Base):
|
||||
__tablename__ = "plugins"
|
||||
|
||||
name: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
version: Mapped[str] = mapped_column(String, nullable=False)
|
||||
entry_point_kind: Mapped[str] = mapped_column(String, nullable=False)
|
||||
target: Mapped[str] = mapped_column(String, nullable=False)
|
||||
wired: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
@@ -0,0 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Engine, delete, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from cloud.db_models import (
|
||||
Base,
|
||||
HostRow,
|
||||
PluginRow,
|
||||
PooledDeviceRow,
|
||||
ScheduledTaskRow,
|
||||
)
|
||||
from core.models import utc_now
|
||||
|
||||
|
||||
class SQLAlchemyCloudRepository:
|
||||
"""SQLAlchemy adapter preserving the existing CloudStore CRUD surface."""
|
||||
|
||||
def __init__(self, engine: Engine, *, create_schema: bool = True) -> None:
|
||||
self.engine = engine
|
||||
self._sessions = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
if create_schema:
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
def upsert_host(
|
||||
self,
|
||||
host_id: str,
|
||||
*,
|
||||
address: str | None,
|
||||
last_seen_at: datetime,
|
||||
) -> None:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(HostRow, host_id)
|
||||
if row is None:
|
||||
session.add(
|
||||
HostRow(
|
||||
host_id=host_id,
|
||||
address=address,
|
||||
last_seen_at=_iso(last_seen_at),
|
||||
)
|
||||
)
|
||||
return
|
||||
if address is not None:
|
||||
row.address = address
|
||||
row.last_seen_at = _iso(last_seen_at)
|
||||
|
||||
def replace_host_devices(
|
||||
self,
|
||||
host_id: str,
|
||||
devices: list[Any],
|
||||
) -> None:
|
||||
with self._sessions.begin() as session:
|
||||
session.execute(
|
||||
delete(PooledDeviceRow).where(PooledDeviceRow.host_id == host_id)
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
PooledDeviceRow(
|
||||
device_id=device.device_id,
|
||||
host_id=device.host_id,
|
||||
driver_type=device.driver_type,
|
||||
status=device.status,
|
||||
capability_tags_json=json.dumps(
|
||||
list(device.capability_tags),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
synced_at=_iso(device.synced_at) if device.synced_at else None,
|
||||
)
|
||||
for device in devices
|
||||
]
|
||||
)
|
||||
|
||||
def list_hosts(self) -> list[Any]:
|
||||
with self._sessions() as session:
|
||||
rows = session.scalars(select(HostRow).order_by(HostRow.host_id)).all()
|
||||
return [_host_from_row(row) for row in rows]
|
||||
|
||||
def get_host(self, host_id: str) -> Any | None:
|
||||
with self._sessions() as session:
|
||||
row = session.get(HostRow, host_id)
|
||||
return _host_from_row(row) if row else None
|
||||
|
||||
def list_devices(self) -> list[Any]:
|
||||
with self._sessions() as session:
|
||||
rows = session.scalars(
|
||||
select(PooledDeviceRow).order_by(
|
||||
PooledDeviceRow.host_id,
|
||||
PooledDeviceRow.device_id,
|
||||
)
|
||||
).all()
|
||||
return [_device_from_row(row) for row in rows]
|
||||
|
||||
def get_device(self, device_id: str) -> Any | None:
|
||||
with self._sessions() as session:
|
||||
row = session.scalars(
|
||||
select(PooledDeviceRow)
|
||||
.where(PooledDeviceRow.device_id == device_id)
|
||||
.order_by(PooledDeviceRow.host_id)
|
||||
.limit(1)
|
||||
).first()
|
||||
return _device_from_row(row) if row else None
|
||||
|
||||
def enqueue_task(self, task: Any) -> None:
|
||||
with self._sessions.begin() as session:
|
||||
session.add(
|
||||
ScheduledTaskRow(
|
||||
id=task.id,
|
||||
goal=task.goal,
|
||||
workflow_definition_id=task.workflow_definition_id,
|
||||
constraints_json=json.dumps(
|
||||
asdict(task.constraints),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
status=task.status,
|
||||
assigned_device_id=task.assigned_device_id,
|
||||
assigned_host_id=task.assigned_host_id,
|
||||
created_at=_iso(task.created_at),
|
||||
)
|
||||
)
|
||||
|
||||
def list_queued_tasks(self) -> list[Any]:
|
||||
with self._sessions() as session:
|
||||
rows = session.scalars(
|
||||
select(ScheduledTaskRow)
|
||||
.where(ScheduledTaskRow.status == "queued")
|
||||
.order_by(ScheduledTaskRow.created_at, ScheduledTaskRow.id)
|
||||
).all()
|
||||
return [_task_from_row(row) for row in rows]
|
||||
|
||||
def get_task(self, task_id: str) -> Any | None:
|
||||
with self._sessions() as session:
|
||||
row = session.get(ScheduledTaskRow, task_id)
|
||||
return _task_from_row(row) if row else None
|
||||
|
||||
def update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
assigned_device_id: str | None = None,
|
||||
assigned_host_id: str | None = None,
|
||||
) -> None:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(ScheduledTaskRow, task_id)
|
||||
if row is None:
|
||||
return
|
||||
if status is not None:
|
||||
row.status = status
|
||||
if assigned_device_id is not None:
|
||||
row.assigned_device_id = assigned_device_id
|
||||
if assigned_host_id is not None:
|
||||
row.assigned_host_id = assigned_host_id
|
||||
|
||||
def count_queued_tasks(self) -> int:
|
||||
with self._sessions() as session:
|
||||
count = session.scalar(
|
||||
select(func.count())
|
||||
.select_from(ScheduledTaskRow)
|
||||
.where(ScheduledTaskRow.status == "queued")
|
||||
)
|
||||
return int(count or 0)
|
||||
|
||||
def save_plugin(self, manifest: Any, *, wired: bool) -> None:
|
||||
with self._sessions.begin() as session:
|
||||
row = session.get(PluginRow, manifest.name)
|
||||
if row is None:
|
||||
session.add(
|
||||
PluginRow(
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
entry_point_kind=manifest.entry_point_kind,
|
||||
target=manifest.target,
|
||||
wired=1 if wired else 0,
|
||||
)
|
||||
)
|
||||
return
|
||||
row.version = manifest.version
|
||||
row.entry_point_kind = manifest.entry_point_kind
|
||||
row.target = manifest.target
|
||||
row.wired = 1 if wired else 0
|
||||
|
||||
def list_plugins(self) -> list[tuple[Any, bool]]:
|
||||
with self._sessions() as session:
|
||||
rows = session.scalars(select(PluginRow).order_by(PluginRow.name)).all()
|
||||
return [_plugin_from_row(row) for row in rows]
|
||||
|
||||
def get_plugin(self, name: str) -> tuple[Any, bool] | None:
|
||||
with self._sessions() as session:
|
||||
row = session.get(PluginRow, name)
|
||||
return _plugin_from_row(row) if row else None
|
||||
|
||||
def health_check(self) -> None:
|
||||
with self._sessions() as session:
|
||||
session.execute(select(1))
|
||||
|
||||
def close(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _host_from_row(row: HostRow) -> Any:
|
||||
from cloud.pool import HostRegistration
|
||||
|
||||
return HostRegistration(
|
||||
host_id=row.host_id,
|
||||
address=row.address,
|
||||
last_seen_at=_parse_dt(row.last_seen_at) or utc_now(),
|
||||
)
|
||||
|
||||
|
||||
def _device_from_row(row: PooledDeviceRow) -> Any:
|
||||
from cloud.pool import PooledDevice
|
||||
|
||||
try:
|
||||
tags = list(json.loads(row.capability_tags_json))
|
||||
except (TypeError, ValueError):
|
||||
tags = []
|
||||
return PooledDevice(
|
||||
device_id=row.device_id,
|
||||
host_id=row.host_id,
|
||||
driver_type=row.driver_type,
|
||||
status=row.status,
|
||||
capability_tags=tags,
|
||||
synced_at=_parse_dt(row.synced_at),
|
||||
)
|
||||
|
||||
|
||||
def _task_from_row(row: ScheduledTaskRow) -> Any:
|
||||
from cloud.scheduler import ScheduledTask, TaskConstraints
|
||||
|
||||
try:
|
||||
constraints_data = json.loads(row.constraints_json)
|
||||
except (TypeError, ValueError):
|
||||
constraints_data = {}
|
||||
return ScheduledTask(
|
||||
id=row.id,
|
||||
goal=row.goal,
|
||||
workflow_definition_id=row.workflow_definition_id,
|
||||
constraints=TaskConstraints(
|
||||
driver_type=constraints_data.get("driver_type"),
|
||||
capability_tags=list(constraints_data.get("capability_tags") or []),
|
||||
),
|
||||
status=row.status,
|
||||
assigned_device_id=row.assigned_device_id,
|
||||
assigned_host_id=row.assigned_host_id,
|
||||
created_at=_parse_dt(row.created_at) or utc_now(),
|
||||
)
|
||||
|
||||
|
||||
def _plugin_from_row(row: PluginRow) -> tuple[Any, bool]:
|
||||
from cloud.plugins import PluginManifest
|
||||
|
||||
return (
|
||||
PluginManifest(
|
||||
name=row.name,
|
||||
version=row.version,
|
||||
entry_point_kind=row.entry_point_kind,
|
||||
target=row.target,
|
||||
),
|
||||
bool(row.wired),
|
||||
)
|
||||
@@ -1,415 +1,19 @@
|
||||
"""SQLite-backed store for cloud runtime state.
|
||||
|
||||
Owns ``cloud/cloud.sqlite3`` with four tables: ``host_registrations``,
|
||||
``pooled_devices``, ``scheduled_tasks``, and ``plugins``. Uses the same
|
||||
connect-per-call ``sqlite3`` pattern as ``storage/task_metadata.py`` and
|
||||
``workflow/store.py``.
|
||||
"""
|
||||
"""Compatibility facade for SQLAlchemy-backed cloud persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from core.models import utc_now
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.pool import HostRegistration, PooledDevice
|
||||
from cloud.plugins import PluginManifest
|
||||
from cloud.scheduler import ScheduledTask
|
||||
from cloud.sql_repository import SQLAlchemyCloudRepository
|
||||
|
||||
|
||||
class CloudStore:
|
||||
"""Persisted state for the cloud runtime (hosts, devices, tasks, plugins)."""
|
||||
class CloudStore(SQLAlchemyCloudRepository):
|
||||
"""Preserve the historical ``CloudStore(path)`` SQLite API."""
|
||||
|
||||
def __init__(self, db_path: str | Path = "cloud/cloud.sqlite3") -> None:
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._ensure_schema()
|
||||
|
||||
# ------------------------------------------------------------------ hosts
|
||||
|
||||
def upsert_host(
|
||||
self,
|
||||
host_id: str,
|
||||
*,
|
||||
address: str | None,
|
||||
last_seen_at: Any,
|
||||
) -> None:
|
||||
"""Insert or update a host row.
|
||||
|
||||
If ``address`` is None and the host already exists, the existing
|
||||
address is preserved (a heartbeat sync should not blow away a
|
||||
previously-registered address).
|
||||
"""
|
||||
with self._connect() as connection:
|
||||
existing = connection.execute(
|
||||
"select address from host_registrations where host_id = ?",
|
||||
(host_id,),
|
||||
).fetchone()
|
||||
preserved_address = (
|
||||
existing["address"] if (address is None and existing is not None) else address
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
insert into host_registrations (host_id, address, last_seen_at)
|
||||
values (?, ?, ?)
|
||||
on conflict(host_id) do update set
|
||||
address = excluded.address,
|
||||
last_seen_at = excluded.last_seen_at
|
||||
""",
|
||||
(host_id, preserved_address, _iso(last_seen_at)),
|
||||
)
|
||||
|
||||
def replace_host_devices(
|
||||
self,
|
||||
host_id: str,
|
||||
devices: "list[PooledDevice]",
|
||||
) -> None:
|
||||
"""Atomically replace one host's device rows with the given list."""
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"delete from pooled_devices where host_id = ?",
|
||||
(host_id,),
|
||||
)
|
||||
connection.executemany(
|
||||
"""
|
||||
insert into pooled_devices (
|
||||
device_id, host_id, driver_type, status,
|
||||
capability_tags_json, synced_at
|
||||
) values (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[
|
||||
(
|
||||
d.device_id,
|
||||
d.host_id,
|
||||
d.driver_type,
|
||||
d.status,
|
||||
json.dumps(list(d.capability_tags), ensure_ascii=False),
|
||||
_iso(d.synced_at),
|
||||
)
|
||||
for d in devices
|
||||
],
|
||||
)
|
||||
|
||||
def list_hosts(self) -> "list[HostRegistration]":
|
||||
from cloud.pool import HostRegistration
|
||||
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"select host_id, address, last_seen_at from host_registrations"
|
||||
).fetchall()
|
||||
return [_row_to_host(row) for row in rows]
|
||||
|
||||
def get_host(self, host_id: str) -> "HostRegistration | None":
|
||||
from cloud.pool import HostRegistration
|
||||
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"select host_id, address, last_seen_at from host_registrations where host_id = ?",
|
||||
(host_id,),
|
||||
).fetchone()
|
||||
return _row_to_host(row) if row else None
|
||||
|
||||
def list_devices(self) -> "list[PooledDevice]":
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
select device_id, host_id, driver_type, status,
|
||||
capability_tags_json, synced_at
|
||||
from pooled_devices
|
||||
"""
|
||||
).fetchall()
|
||||
return [_row_to_device(row) for row in rows]
|
||||
|
||||
def get_device(self, device_id: str) -> "PooledDevice | None":
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
select device_id, host_id, driver_type, status,
|
||||
capability_tags_json, synced_at
|
||||
from pooled_devices where device_id = ?
|
||||
""",
|
||||
(device_id,),
|
||||
).fetchone()
|
||||
return _row_to_device(row) if row else None
|
||||
|
||||
# ----------------------------------------------------------- scheduled tasks
|
||||
|
||||
def enqueue_task(self, task: "ScheduledTask") -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
insert into scheduled_tasks (
|
||||
id, goal, workflow_definition_id,
|
||||
constraints_json, status,
|
||||
assigned_device_id, assigned_host_id, created_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
task.id,
|
||||
task.goal,
|
||||
task.workflow_definition_id,
|
||||
json.dumps(asdict(task.constraints), ensure_ascii=False),
|
||||
task.status,
|
||||
task.assigned_device_id,
|
||||
task.assigned_host_id,
|
||||
_iso(task.created_at),
|
||||
),
|
||||
)
|
||||
|
||||
def list_queued_tasks(self) -> "list[ScheduledTask]":
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
select id, goal, workflow_definition_id, constraints_json,
|
||||
status, assigned_device_id, assigned_host_id, created_at
|
||||
from scheduled_tasks
|
||||
where status = 'queued'
|
||||
order by created_at asc, id asc
|
||||
"""
|
||||
).fetchall()
|
||||
return [_row_to_task(row) for row in rows]
|
||||
|
||||
def get_task(self, task_id: str) -> "ScheduledTask | None":
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"""
|
||||
select id, goal, workflow_definition_id, constraints_json,
|
||||
status, assigned_device_id, assigned_host_id, created_at
|
||||
from scheduled_tasks where id = ?
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return _row_to_task(row) if row else None
|
||||
|
||||
def update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
assigned_device_id: str | None = None,
|
||||
assigned_host_id: str | None = None,
|
||||
) -> None:
|
||||
updates: dict[str, Any] = {}
|
||||
if status is not None:
|
||||
updates["status"] = status
|
||||
if assigned_device_id is not None:
|
||||
updates["assigned_device_id"] = assigned_device_id
|
||||
if assigned_host_id is not None:
|
||||
updates["assigned_host_id"] = assigned_host_id
|
||||
if not updates:
|
||||
return
|
||||
assignments = ", ".join(f"{key} = ?" for key in updates)
|
||||
values = [*updates.values(), task_id]
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
f"update scheduled_tasks set {assignments} where id = ?",
|
||||
values,
|
||||
)
|
||||
|
||||
def count_queued_tasks(self) -> int:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"select count(*) as count from scheduled_tasks where status = 'queued'"
|
||||
).fetchone()
|
||||
return int(row["count"])
|
||||
|
||||
# ----------------------------------------------------------------- plugins
|
||||
|
||||
def save_plugin(
|
||||
self,
|
||||
manifest: "PluginManifest",
|
||||
*,
|
||||
wired: bool,
|
||||
) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
insert into plugins (
|
||||
name, version, entry_point_kind, target, wired
|
||||
) values (?, ?, ?, ?, ?)
|
||||
on conflict(name) do update set
|
||||
version = excluded.version,
|
||||
entry_point_kind = excluded.entry_point_kind,
|
||||
target = excluded.target,
|
||||
wired = excluded.wired
|
||||
""",
|
||||
(
|
||||
manifest.name,
|
||||
manifest.version,
|
||||
manifest.entry_point_kind,
|
||||
manifest.target,
|
||||
1 if wired else 0,
|
||||
),
|
||||
)
|
||||
|
||||
def list_plugins(self) -> "list[tuple[PluginManifest, bool]]":
|
||||
from cloud.plugins import PluginManifest
|
||||
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"select name, version, entry_point_kind, target, wired from plugins"
|
||||
).fetchall()
|
||||
return [
|
||||
(
|
||||
PluginManifest(
|
||||
name=row["name"],
|
||||
version=row["version"],
|
||||
entry_point_kind=row["entry_point_kind"],
|
||||
target=row["target"],
|
||||
),
|
||||
bool(row["wired"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_plugin(self, name: str) -> "tuple[PluginManifest, bool] | None":
|
||||
from cloud.plugins import PluginManifest
|
||||
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"select name, version, entry_point_kind, target, wired from plugins where name = ?",
|
||||
(name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
manifest = PluginManifest(
|
||||
name=row["name"],
|
||||
version=row["version"],
|
||||
entry_point_kind=row["entry_point_kind"],
|
||||
target=row["target"],
|
||||
)
|
||||
return (manifest, bool(row["wired"]))
|
||||
|
||||
# ------------------------------------------------------------------- schema
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
create table if not exists host_registrations (
|
||||
host_id text primary key,
|
||||
address text,
|
||||
last_seen_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
create table if not exists pooled_devices (
|
||||
device_id text not null,
|
||||
host_id text not null,
|
||||
driver_type text not null,
|
||||
status text not null,
|
||||
capability_tags_json text not null,
|
||||
synced_at text,
|
||||
primary key (host_id, device_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
create table if not exists scheduled_tasks (
|
||||
id text primary key,
|
||||
goal text,
|
||||
workflow_definition_id text,
|
||||
constraints_json text not null,
|
||||
status text not null,
|
||||
assigned_device_id text,
|
||||
assigned_host_id text,
|
||||
created_at text not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
create table if not exists plugins (
|
||||
name text primary key,
|
||||
version text not null,
|
||||
entry_point_kind text not null,
|
||||
target text not null,
|
||||
wired integer not null
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def _iso(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _parse_dt(value: Any):
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
return datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _row_to_host(row: sqlite3.Row):
|
||||
from cloud.pool import HostRegistration
|
||||
|
||||
return HostRegistration(
|
||||
host_id=row["host_id"],
|
||||
address=row["address"],
|
||||
last_seen_at=_parse_dt(row["last_seen_at"]) or utc_now(),
|
||||
)
|
||||
|
||||
|
||||
def _row_to_device(row: sqlite3.Row):
|
||||
from cloud.pool import PooledDevice
|
||||
|
||||
tags_raw = row["capability_tags_json"]
|
||||
try:
|
||||
tags = list(json.loads(tags_raw)) if tags_raw else []
|
||||
except (TypeError, ValueError):
|
||||
tags = []
|
||||
return PooledDevice(
|
||||
device_id=row["device_id"],
|
||||
host_id=row["host_id"],
|
||||
driver_type=row["driver_type"],
|
||||
status=row["status"],
|
||||
capability_tags=tags,
|
||||
synced_at=_parse_dt(row["synced_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _row_to_task(row: sqlite3.Row):
|
||||
from cloud.scheduler import ScheduledTask, TaskConstraints
|
||||
|
||||
try:
|
||||
constraints_data = json.loads(row["constraints_json"]) if row["constraints_json"] else {}
|
||||
except (TypeError, ValueError):
|
||||
constraints_data = {}
|
||||
constraints = TaskConstraints(
|
||||
driver_type=constraints_data.get("driver_type"),
|
||||
capability_tags=list(constraints_data.get("capability_tags") or []),
|
||||
)
|
||||
return ScheduledTask(
|
||||
id=row["id"],
|
||||
goal=row["goal"],
|
||||
workflow_definition_id=row["workflow_definition_id"],
|
||||
constraints=constraints,
|
||||
status=row["status"],
|
||||
assigned_device_id=row["assigned_device_id"],
|
||||
assigned_host_id=row["assigned_host_id"],
|
||||
created_at=_parse_dt(row["created_at"]) or utc_now(),
|
||||
)
|
||||
engine = create_engine(f"sqlite:///{self.db_path.as_posix()}")
|
||||
super().__init__(engine)
|
||||
|
||||
@@ -6,6 +6,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"device-agent-runtime==0.1.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -487,10 +487,14 @@ version = "0.1.0"
|
||||
source = { editable = "packages/cloud-platform" }
|
||||
dependencies = [
|
||||
{ name = "device-agent-runtime" },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "device-agent-runtime", editable = "." }]
|
||||
requires-dist = [
|
||||
{ name = "device-agent-runtime", editable = "." },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "device-host-agent"
|
||||
@@ -611,6 +615,45 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -1693,6 +1736,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.51"
|
||||
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/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.4.5"
|
||||
|
||||
Reference in New Issue
Block a user