generated from john/python-template
Compare commits
38
Commits
f0b359edf8
...
going-back
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8998025e2 | ||
|
|
002eb572e9 | ||
|
|
d44c7de684 | ||
|
|
282b0fb967 | ||
|
|
a9a47c3906 | ||
|
|
9ada09accf | ||
|
|
67b0980664 | ||
|
|
e35a8ec060 | ||
|
|
3a141bd4cc | ||
|
|
8129f5a9e8 | ||
|
|
58b4c381a4 | ||
|
|
5719debbaa | ||
|
|
e7c7ab71b4 | ||
|
|
ca5c9f787f | ||
|
|
8064821503 | ||
|
|
593388ef3a | ||
|
|
83ee7b31e0 | ||
|
|
455a01d7c4 | ||
|
|
e2e421835f | ||
|
|
57c1d22bb9 | ||
|
|
dba96e7a72 | ||
|
|
912cfd44de | ||
|
|
8d5fee886f | ||
|
|
34468c521f | ||
|
|
b682e092ea | ||
|
|
bd33e338b3 | ||
|
|
57a988973f | ||
|
|
9ad67d55e8 | ||
|
|
7bdc0c6f79 | ||
|
|
4e4bf50219 | ||
|
|
c409b42077 | ||
|
|
f1fb45e0d2 | ||
|
|
f0501d919e | ||
|
|
517d01abe2 | ||
|
|
d967f58358 | ||
|
|
2000f0096b | ||
|
|
aa93080d7d | ||
|
|
52dbf70304 |
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
description: Follow these guidelines when editing the services
|
||||||
|
applyTo: 'src/transcription/services/*.py'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Services
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
- Project core data models defined in [models](../../src/transcription/models.py)
|
||||||
|
- 1 service class per data model
|
||||||
|
- Only services directly interact with the database, and only through async methods
|
||||||
|
- Services are completely independent of one another. Any operation that needs to use more than a single service, which is most of them, needs to have a separate orchestration function.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Service-specific errors defined at the top of the respective module and inherit from `AppError`
|
||||||
|
- Use a context manager for large `try/except` blocks like in [transcription](../../src/transcription/services/transcription.py)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Uses `ServiceBase` for common logic
|
||||||
|
- [ ] CRUD methods created at the top
|
||||||
|
- [ ] Session kwarg for `AsyncSession` to pass in a session object to each method
|
||||||
|
- [ ] Services use `self._session_scope` in their methods to pass the session thru.
|
||||||
|
- Multiple operations on the same object(s) require sharing a session between all the methods used.
|
||||||
|
|
||||||
|
## CRUD Methods
|
||||||
|
|
||||||
|
- Create, read, update, and delete, created in that order
|
||||||
|
- Name format `<operation>_<model >`, for example `create_document` or `update_job`
|
||||||
|
- All services must define these 4 methods first, and in that order
|
||||||
|
|
||||||
|
## Transaction Finalization
|
||||||
|
|
||||||
|
When a service method accepts an optional `session` kwarg, write methods must use `self._finalize` to finalize the transaction properly according to whether or not they are sharing a session.
|
||||||
|
|
||||||
|
- If `session` is `None`: the method owns the transaction and should `commit()`.
|
||||||
|
- If `session` is provided: the method must **not** commit; it should `flush()` so IDs and FK values are available to the caller's transaction.
|
||||||
|
- Use `refresh()` on returned ORM objects when the caller needs DB-populated values (defaults, triggers, merged state).
|
||||||
|
|
||||||
|
Recommended helper behavior:
|
||||||
|
|
||||||
|
- Inputs: active session object, original `session` arg (or a boolean ownership flag), and an optional list of objects to refresh.
|
||||||
|
- Logic: `commit` when service-owned session, `flush` when caller-owned session, then refresh requested objects.
|
||||||
|
|
||||||
|
This keeps orchestration functions atomic: they can pass one shared session across multiple services and commit exactly once at the workflow boundary.
|
||||||
|
|
||||||
|
## Workflow Transaction Boundaries
|
||||||
|
|
||||||
|
For multi-step job lifecycles (for example queued transcription jobs), orchestration functions must use explicit transaction phases.
|
||||||
|
|
||||||
|
Required boundary model:
|
||||||
|
|
||||||
|
- **Transaction A (claim):** transition `JobStatus.QUEUED -> JobStatus.PROCESSING` and commit immediately.
|
||||||
|
- Perform provider/network work **outside** database transactions.
|
||||||
|
- **Transaction B (terminal success):** write transcript content and set `JobStatus.TRANSCRIBED` in the same shared-session commit.
|
||||||
|
- **Transaction B (terminal failure):** write transcript error detail and set `JobStatus.FAILED` in the same shared-session commit.
|
||||||
|
- **Transaction C (retry path):** write transcript error detail, increment retry count, and set `JobStatus.QUEUED` in one shared-session commit.
|
||||||
|
|
||||||
|
Atomicity rules:
|
||||||
|
|
||||||
|
- Never commit transcript updates separately from the paired terminal/retry job status change.
|
||||||
|
- Terminal state (`TRANSCRIBED` or `FAILED`) and transcript row changes must succeed or roll back together.
|
||||||
|
- Retry persistence (`QUEUED` + retry increment + error detail) must succeed or roll back together.
|
||||||
|
|
||||||
|
Separation of concerns:
|
||||||
|
|
||||||
|
- Worker modules should stay lightweight and delegate lifecycle transitions to service/workflow orchestration functions.
|
||||||
|
- In `workflows.py`, `process_queued_job` should own one complete attempt lifecycle: `QUEUED -> PROCESSING -> TRANSCRIBED|FAILED`.
|
||||||
|
- In `workflows.py`, `advance_job` should coordinate broader status progression around attempts (for example retry scheduling from `FAILED -> QUEUED`).
|
||||||
|
- Services should expose session-aware write helpers (flush on caller-owned session) so orchestration controls commit boundaries.
|
||||||
|
- Backoff/sleep behavior must run outside transactional scopes.
|
||||||
|
|
||||||
|
# Service Composition
|
||||||
|
|
||||||
|
Some operations, like uploading a picutre, require modifications to multiple tables, which can be done by composing methods from the service object into a separate function.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
description: Copilot rules for modifying the UI
|
||||||
|
applyTo: 'src/transcription/ui/**/*.py'
|
||||||
|
---
|
||||||
|
|
||||||
|
The UI is forbidden from directly touching the database. All interactions should be done using the [services](../../src/transcription/services/)
|
||||||
@@ -14,3 +14,8 @@ wheels/
|
|||||||
|
|
||||||
# SQLite database
|
# SQLite database
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
upload/
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.png
|
||||||
|
|||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python: Debug transcription app",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"module": "debugpy",
|
||||||
|
"args": [
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
"transcription.app:create_app",
|
||||||
|
"--factory",
|
||||||
|
"--host",
|
||||||
|
// "127.0.0.1",
|
||||||
|
"0.0.0.0",
|
||||||
|
"--port",
|
||||||
|
"8080"
|
||||||
|
],
|
||||||
|
"justMyCode": true,
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"env": {
|
||||||
|
"PYTHONPATH": "${workspaceFolder}/src"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+6
-5
@@ -23,15 +23,16 @@ dependencies = [
|
|||||||
"sqlmodel>=0.0.25",
|
"sqlmodel>=0.0.25",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest>=8.0",
|
|
||||||
"pytest-asyncio>=0.25",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.25",
|
||||||
|
"httpx2>=2.5.0",
|
||||||
|
"ipykernel>=7.3.0",
|
||||||
|
"ipywidgets>=8.1.8",
|
||||||
"pre-commit>=4.6.0",
|
"pre-commit>=4.6.0",
|
||||||
|
"rich>=15.0.0",
|
||||||
"ruff>=0.15.20",
|
"ruff>=0.15.20",
|
||||||
"ty>=0.0.54",
|
"ty>=0.0.54",
|
||||||
]
|
]
|
||||||
|
|||||||
+32
-44
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
from contextlib import AsyncExitStack
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from contextlib import suppress
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi import status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from .api.errors import register_error_handlers
|
from .api.errors import register_error_handlers
|
||||||
from .api.health import router as health_router
|
from .api.health import router as health_router
|
||||||
@@ -17,49 +19,14 @@ from .db import dispose_database_runtime
|
|||||||
from .db import initialize_database_runtime
|
from .db import initialize_database_runtime
|
||||||
from .services import ServiceBundle
|
from .services import ServiceBundle
|
||||||
from .ui import register_pages
|
from .ui import register_pages
|
||||||
from .worker import run_worker_loop
|
from .worker import worker_consumer_lifespan
|
||||||
|
|
||||||
|
|
||||||
def _start_worker(app: FastAPI) -> None:
|
|
||||||
stop_event = asyncio.Event()
|
|
||||||
wake_queue: asyncio.Queue[None] = asyncio.Queue()
|
|
||||||
worker_task = asyncio.create_task(
|
|
||||||
run_worker_loop(
|
|
||||||
session_factory=app.state.runtime.session_factory,
|
|
||||||
stop_event=stop_event,
|
|
||||||
wake_queue=wake_queue,
|
|
||||||
poll_interval_seconds=1.0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
wake_queue.put_nowait(None)
|
|
||||||
app.state.worker_stop_event = stop_event
|
|
||||||
app.state.worker_wake_queue = wake_queue
|
|
||||||
app.state.worker_task = worker_task
|
|
||||||
|
|
||||||
|
|
||||||
async def _stop_worker(app: FastAPI) -> None:
|
|
||||||
stop_event = getattr(app.state, "worker_stop_event", None)
|
|
||||||
wake_queue = getattr(app.state, "worker_wake_queue", None)
|
|
||||||
worker_task = getattr(app.state, "worker_task", None)
|
|
||||||
|
|
||||||
if stop_event is not None:
|
|
||||||
stop_event.set()
|
|
||||||
if wake_queue is not None:
|
|
||||||
wake_queue.put_nowait(None)
|
|
||||||
if worker_task is not None:
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(worker_task, timeout=2.0)
|
|
||||||
except TimeoutError:
|
|
||||||
worker_task.cancel()
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await worker_task
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _lifespan(app: FastAPI):
|
async def _lifespan(app: FastAPI):
|
||||||
configure_logging()
|
configure_logging()
|
||||||
|
|
||||||
settings = get_settings()
|
settings = getattr(app.state, "settings", None) or get_settings()
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.services = ServiceBundle()
|
app.state.services = ServiceBundle()
|
||||||
app.state.runtime = initialize_database_runtime(settings=settings)
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
@@ -70,17 +37,38 @@ async def _lifespan(app: FastAPI):
|
|||||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
settings.prompt_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
_start_worker(app)
|
async with AsyncExitStack() as stack:
|
||||||
try:
|
stack.push_async_callback(dispose_database_runtime)
|
||||||
|
stop_event, worker_notifier = await stack.enter_async_context(
|
||||||
|
worker_consumer_lifespan(
|
||||||
|
session_factory=app.state.runtime.session_factory,
|
||||||
|
poll_interval_seconds=1.0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
app.state.worker_stop_event = stop_event
|
||||||
|
app.state.worker_notifier = worker_notifier
|
||||||
yield
|
yield
|
||||||
finally:
|
|
||||||
await _stop_worker(app)
|
|
||||||
await dispose_database_runtime()
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application."""
|
"""Create and configure the FastAPI application."""
|
||||||
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
app = FastAPI(title="Transcription", lifespan=_lifespan)
|
||||||
|
settings = get_settings()
|
||||||
|
app.state.settings = settings
|
||||||
|
app.mount(
|
||||||
|
"/uploads",
|
||||||
|
StaticFiles(directory=settings.upload_dir, check_dir=False),
|
||||||
|
name="uploads",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/", include_in_schema=False)
|
||||||
|
async def root_redirect() -> RedirectResponse:
|
||||||
|
return RedirectResponse(url="/ui", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||||
|
|
||||||
|
@app.get("/ui", include_in_schema=False)
|
||||||
|
async def ui_redirect() -> RedirectResponse:
|
||||||
|
return RedirectResponse(url="/ui/upload", status_code=status.HTTP_307_TEMPORARY_REDIRECT)
|
||||||
|
|
||||||
register_error_handlers(app)
|
register_error_handlers(app)
|
||||||
register_pages(app)
|
register_pages(app)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Helpers for accessing lifespan-owned application state resources."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from transcription.db.runtime import DatabaseRuntime
|
||||||
|
from transcription.db.runtime import get_session_factory
|
||||||
|
from transcription.worker import WorkerNotifier
|
||||||
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_database_runtime(state: object) -> DatabaseRuntime | None:
|
||||||
|
"""Return database runtime from app-like state objects when available."""
|
||||||
|
runtime = getattr(state, "runtime", None)
|
||||||
|
return runtime if isinstance(runtime, DatabaseRuntime) else None
|
||||||
|
|
||||||
|
|
||||||
|
def require_database_runtime(state: object) -> DatabaseRuntime:
|
||||||
|
"""Return database runtime or raise when app lifespan has not initialized it."""
|
||||||
|
runtime = resolve_database_runtime(state)
|
||||||
|
if runtime is None:
|
||||||
|
raise RuntimeError("Database runtime is not initialized on application state")
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_session_factory(state: object) -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Return DB session factory from state when available, otherwise shared runtime."""
|
||||||
|
runtime = resolve_database_runtime(state)
|
||||||
|
if runtime is not None:
|
||||||
|
return runtime.session_factory
|
||||||
|
return get_session_factory()
|
||||||
|
|
||||||
|
|
||||||
|
def get_worker_notifier(app: FastAPI) -> WorkerNotifier:
|
||||||
|
"""Return app worker notifier, or a no-op fallback when unavailable."""
|
||||||
|
return resolve_worker_notifier(app.state)
|
||||||
@@ -75,7 +75,7 @@ LOGGING_CONFIG: dict[str, object] = {
|
|||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"formatters": {
|
"formatters": {
|
||||||
"standard": {
|
"standard": {
|
||||||
"format": "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
"format": "%(asctime)s %(levelname)-8s | %(message)s",
|
||||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,10 +51,15 @@ def _ensure_sqlite_compat_columns(connection: Connection) -> None:
|
|||||||
|
|
||||||
inspector = inspect(connection)
|
inspector = inspect(connection)
|
||||||
table_names = set(inspector.get_table_names())
|
table_names = set(inspector.get_table_names())
|
||||||
if "job" not in table_names:
|
|
||||||
return
|
|
||||||
|
|
||||||
columns = {column["name"] for column in inspector.get_columns("job")}
|
if "job" in table_names:
|
||||||
if "retry_count" not in columns:
|
job_columns = {column["name"] for column in inspector.get_columns("job")}
|
||||||
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
|
if "retry_count" not in job_columns:
|
||||||
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
|
connection.execute(text("ALTER TABLE job ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"))
|
||||||
|
logger.warning("Applied SQLite compatibility schema patch table=job column=retry_count default=0")
|
||||||
|
|
||||||
|
if "transcript" in table_names:
|
||||||
|
transcript_columns = {column["name"] for column in inspector.get_columns("transcript")}
|
||||||
|
if "model" not in transcript_columns:
|
||||||
|
connection.execute(text("ALTER TABLE transcript ADD COLUMN model VARCHAR NOT NULL DEFAULT 'unknown'"))
|
||||||
|
logger.warning("Applied SQLite compatibility schema patch table=transcript column=model default=unknown")
|
||||||
|
|||||||
+27
-17
@@ -1,16 +1,16 @@
|
|||||||
"""SQLModel domain models for the transcription system.
|
"""SQLModel domain models for the transcription system.
|
||||||
|
|
||||||
Three models capture the MVP lifecycle:
|
Three models capture the MVP lifecycle:
|
||||||
Document -> one-to-many -> Job -> one-to-one -> Transcript
|
Document -> one-to-many -> Job -> one-to-many -> Transcript
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
from sqlmodel import Field
|
from sqlmodel import Field
|
||||||
from sqlmodel import Relationship
|
from sqlmodel import Relationship
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
@@ -29,9 +29,7 @@ class Document(SQLModel, table=True):
|
|||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
filename: str
|
filename: str
|
||||||
file_path: str
|
file_path: str
|
||||||
uploaded_at: datetime = Field(
|
uploaded_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
default_factory=lambda: datetime.now(UTC),
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- relationships ---
|
# --- relationships ---
|
||||||
jobs: list["Job"] = Relationship(back_populates="document")
|
jobs: list["Job"] = Relationship(back_populates="document")
|
||||||
@@ -44,28 +42,40 @@ class Job(SQLModel, table=True):
|
|||||||
document_id: UUID = Field(foreign_key="document.id")
|
document_id: UUID = Field(foreign_key="document.id")
|
||||||
status: JobStatus = Field(default=JobStatus.QUEUED)
|
status: JobStatus = Field(default=JobStatus.QUEUED)
|
||||||
retry_count: int = Field(default=0, ge=0)
|
retry_count: int = Field(default=0, ge=0)
|
||||||
created_at: datetime = Field(
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
default_factory=lambda: datetime.now(UTC),
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
)
|
|
||||||
updated_at: datetime = Field(
|
|
||||||
default_factory=lambda: datetime.now(UTC),
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- relationships ---
|
# --- relationships ---
|
||||||
document: Document = Relationship(back_populates="jobs")
|
document: Document = Relationship(back_populates="jobs")
|
||||||
transcript: Optional["Transcript"] = Relationship(back_populates="job")
|
transcripts: list["Transcript"] = Relationship(back_populates="job")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filename(self) -> str:
|
||||||
|
"""Return the filename of the associated document."""
|
||||||
|
return self.document.filename if self.document else "unknown"
|
||||||
|
|
||||||
|
|
||||||
class Transcript(SQLModel, table=True):
|
class Transcript(SQLModel, table=True):
|
||||||
"""The output of a transcription job."""
|
"""The output of a transcription job."""
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
job_id: UUID = Field(foreign_key="job.id", unique=True)
|
job_id: UUID = Field(foreign_key="job.id")
|
||||||
|
"""ID for the associated job."""
|
||||||
|
revision: int = Field(default=0, ge=0)
|
||||||
|
"""Revision number for this job's transcript history, starting at 0."""
|
||||||
|
provider: str
|
||||||
|
"""Name of the transcription provider used to generate this transcript."""
|
||||||
|
model: str
|
||||||
|
"""Model identifier used to generate this transcript revision."""
|
||||||
|
prompt_name: str
|
||||||
|
"""Name of the prompt used to generate this transcript."""
|
||||||
text: str | None = None
|
text: str | None = None
|
||||||
|
"""The transcribed text. This may be None if the job failed or is still in progress."""
|
||||||
error_detail: str | None = None
|
error_detail: str | None = None
|
||||||
created_at: datetime = Field(
|
"""Details of any error that occurred during transcription."""
|
||||||
default_factory=lambda: datetime.now(UTC),
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||||
)
|
|
||||||
|
__table_args__ = (UniqueConstraint("job_id", "revision", name="uq_transcript_job_revision"),)
|
||||||
|
|
||||||
# --- relationships ---
|
# --- relationships ---
|
||||||
job: Job = Relationship(back_populates="transcript")
|
job: Job = Relationship(back_populates="transcripts")
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from ..models import Transcript
|
||||||
|
|
||||||
|
|
||||||
class ProviderError(RuntimeError):
|
class ProviderError(RuntimeError):
|
||||||
@@ -22,12 +25,24 @@ class TranscriptionResult:
|
|||||||
|
|
||||||
text: str
|
text: str
|
||||||
provider: str
|
provider: str
|
||||||
|
prompt_name: str
|
||||||
model: str
|
model: str
|
||||||
|
|
||||||
|
def to_transcript(self, job_id: UUID, *, revision: int = 0) -> Transcript:
|
||||||
|
"""Convert a TranscriptionResult to a Transcript model instance."""
|
||||||
|
return Transcript(
|
||||||
|
job_id=job_id,
|
||||||
|
revision=revision,
|
||||||
|
provider=self.provider,
|
||||||
|
prompt_name=self.prompt_name,
|
||||||
|
model=self.model,
|
||||||
|
text=self.text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TranscriptionProvider(Protocol):
|
class TranscriptionProvider(Protocol):
|
||||||
"""Contract every transcription provider adapter must satisfy."""
|
"""Contract every transcription provider adapter must satisfy."""
|
||||||
|
|
||||||
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
||||||
"""Transcribe the provided image according to the prompt text."""
|
"""Transcribe the provided image according to the prompt text."""
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import base64
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from openrouter import OpenRouter
|
from openrouter import OpenRouter
|
||||||
|
from openrouter.components.chatmessages import ChatMessagesTypedDict
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
from transcription.config import get_settings
|
from transcription.config import get_settings
|
||||||
@@ -44,12 +46,12 @@ class OpenRouterTranscriptionProvider:
|
|||||||
"""Return the resolved OpenRouter model slug."""
|
"""Return the resolved OpenRouter model slug."""
|
||||||
return self._model
|
return self._model
|
||||||
|
|
||||||
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
async def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
||||||
"""Send prompt + image to OpenRouter and return normalized text output."""
|
"""Send prompt + image to OpenRouter and return normalized text output."""
|
||||||
request = self._build_request(prompt_text=prompt_text, image_bytes=image_bytes, mime_type=mime_type)
|
request = self._build_request(prompt_text=prompt_text, image_bytes=image_bytes, mime_type=mime_type)
|
||||||
try:
|
try:
|
||||||
response = self._client.chat.send(
|
response = await self._client.chat.send_async(
|
||||||
messages=request.messages,
|
messages=cast(list[ChatMessagesTypedDict], request.messages),
|
||||||
model=request.model,
|
model=request.model,
|
||||||
http_referer=request.http_referer,
|
http_referer=request.http_referer,
|
||||||
x_open_router_title=request.x_open_router_title,
|
x_open_router_title=request.x_open_router_title,
|
||||||
@@ -63,7 +65,7 @@ class OpenRouterTranscriptionProvider:
|
|||||||
text = self._extract_text(response)
|
text = self._extract_text(response)
|
||||||
model = self._get_optional_attr(response, "model") or self.model
|
model = self._get_optional_attr(response, "model") or self.model
|
||||||
logger.info("OpenRouter transcription completed using model=%s", model)
|
logger.info("OpenRouter transcription completed using model=%s", model)
|
||||||
return TranscriptionResult(text=text, provider="openrouter", model=model)
|
return TranscriptionResult(text=text, provider="openrouter", prompt_name="", model=model)
|
||||||
|
|
||||||
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
|
def _build_request(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> OpenRouterRequest:
|
||||||
image_b64 = base64.b64encode(image_bytes).decode("ascii")
|
image_b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
|
from collections.abc import Sequence
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
@@ -36,3 +37,24 @@ class ServiceBase(ABC):
|
|||||||
# Otherwise, create a new session for this scope
|
# Otherwise, create a new session for this scope
|
||||||
async with self.session_factory() as new_session:
|
async with self.session_factory() as new_session:
|
||||||
yield new_session
|
yield new_session
|
||||||
|
|
||||||
|
async def _finalize(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
caller_session: AsyncSession | None,
|
||||||
|
refresh: Sequence[object] = (),
|
||||||
|
) -> None:
|
||||||
|
"""Finalize a write based on transaction ownership.
|
||||||
|
|
||||||
|
Service-owned sessions commit immediately. Caller-owned sessions flush so
|
||||||
|
orchestration code can commit once at a larger transaction boundary.
|
||||||
|
"""
|
||||||
|
should_commit = caller_session is None
|
||||||
|
if should_commit:
|
||||||
|
await session.commit()
|
||||||
|
else:
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
for obj in refresh:
|
||||||
|
await session.refresh(obj)
|
||||||
|
|||||||
@@ -9,15 +9,10 @@ from sqlalchemy.orm import selectinload
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from ..config import Settings
|
|
||||||
from ..config import get_settings
|
|
||||||
from ..errors import AppError
|
from ..errors import AppError
|
||||||
from ..errors import ErrorCategory
|
from ..errors import ErrorCategory
|
||||||
from ..models import Document
|
from ..models import Document
|
||||||
from ..models import Job
|
|
||||||
from ..models import JobStatus
|
|
||||||
from .base import ServiceBase
|
from .base import ServiceBase
|
||||||
from .store import store_file
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -55,28 +50,32 @@ class DocumentService(ServiceBase):
|
|||||||
# CRUD Operations
|
# CRUD Operations
|
||||||
#
|
#
|
||||||
|
|
||||||
async def create_document(self, document: Document) -> Document:
|
async def create_document(
|
||||||
|
self,
|
||||||
|
document: Document,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Document:
|
||||||
"""Create a new document in the database."""
|
"""Create a new document in the database."""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
session.add(document)
|
_session.add(document)
|
||||||
try:
|
try:
|
||||||
await session.commit()
|
await self._finalize(session=_session, caller_session=session, refresh=(document,))
|
||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
raise DocumentAlreadyExistsError(
|
raise DocumentAlreadyExistsError(
|
||||||
f"Document with id {document.id} already exists",
|
f"Document with id {document.id} already exists",
|
||||||
category=ErrorCategory.VALIDATION,
|
category=ErrorCategory.VALIDATION,
|
||||||
suggestion="Rename the file and try again.",
|
suggestion="Rename the file and try again.",
|
||||||
) from exc
|
) from exc
|
||||||
await session.refresh(document)
|
|
||||||
return document
|
return document
|
||||||
|
|
||||||
async def read_document(self, document_id: UUID) -> Document:
|
async def read_document(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
|
||||||
"""Read an existing document from the database.
|
"""Read an existing document from the database.
|
||||||
|
|
||||||
The selectinload option is used to eagerly load related jobs for the document.
|
The selectinload option is used to eagerly load related jobs for the document.
|
||||||
"""
|
"""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
document = await session.get(
|
document = await _session.get(
|
||||||
Document,
|
Document,
|
||||||
document_id,
|
document_id,
|
||||||
options=(selectinload(Document.jobs),), # pyright: ignore[reportArgumentType]
|
options=(selectinload(Document.jobs),), # pyright: ignore[reportArgumentType]
|
||||||
@@ -95,103 +94,34 @@ class DocumentService(ServiceBase):
|
|||||||
)
|
)
|
||||||
return document
|
return document
|
||||||
|
|
||||||
async def update_document(self, document: Document) -> Document:
|
async def update_document(self, document: Document, *, session: AsyncSession | None = None) -> Document:
|
||||||
"""Update an existing document in the database."""
|
"""Update an existing document in the database."""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
await session.merge(document)
|
merged = await _session.merge(document)
|
||||||
await session.commit()
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
await session.refresh(document)
|
return merged
|
||||||
return document
|
|
||||||
|
|
||||||
async def delete_document(self, document: Document) -> None:
|
async def delete_document(self, document: Document, *, session: AsyncSession | None = None) -> None:
|
||||||
"""Delete a document from the database."""
|
"""Delete a document from the database."""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
await session.delete(document)
|
await _session.delete(document)
|
||||||
await session.commit()
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
# Query Operations
|
# Query Operations
|
||||||
|
|
||||||
async def query_documents(self, *, filename: str | None = None) -> Sequence[Document]:
|
async def query_documents(
|
||||||
|
self, *, filename: str | None = None, session: AsyncSession | None = None
|
||||||
|
) -> Sequence[Document]:
|
||||||
"""Query documents from the database based on provided filters."""
|
"""Query documents from the database based on provided filters."""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Document)
|
query = select(Document)
|
||||||
if filename is not None:
|
if filename is not None:
|
||||||
query = query.where(Document.filename == filename)
|
query = query.where(Document.filename == filename)
|
||||||
result = await session.exec(query)
|
result = await _session.exec(query)
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
async def list_documents(self) -> Sequence[Document]:
|
async def list_documents(self, *, session: AsyncSession | None = None) -> Sequence[Document]:
|
||||||
"""List all documents in the database."""
|
"""List all documents in the database."""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
result = await session.exec(select(Document))
|
result = await _session.exec(select(Document))
|
||||||
return result.all()
|
return result.all()
|
||||||
|
|
||||||
|
|
||||||
async def create_upload_job(
|
|
||||||
*,
|
|
||||||
filename: str,
|
|
||||||
file_bytes: bytes,
|
|
||||||
session: AsyncSession,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
) -> UploadJobResult:
|
|
||||||
"""Create upload-backed document and queued job records."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
stored_path = store_file(
|
|
||||||
filename=filename,
|
|
||||||
file_bytes=file_bytes,
|
|
||||||
settings=runtime_settings,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
document, job = await _create_upload_records(
|
|
||||||
session=session,
|
|
||||||
original_filename=filename,
|
|
||||||
stored_path=stored_path,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
_best_effort_delete(stored_path)
|
|
||||||
raise UploadError(
|
|
||||||
"Failed to create upload database records",
|
|
||||||
category=ErrorCategory.INFRA_TRANSIENT,
|
|
||||||
suggestion="Retry upload. If this keeps happening, verify database availability.",
|
|
||||||
retriable=True,
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
|
|
||||||
return UploadJobResult(
|
|
||||||
document_id=document.id,
|
|
||||||
job_id=job.id,
|
|
||||||
stored_path=stored_path,
|
|
||||||
original_filename=Path(filename).name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_upload_records(
|
|
||||||
*,
|
|
||||||
session: AsyncSession,
|
|
||||||
original_filename: str,
|
|
||||||
stored_path: Path,
|
|
||||||
) -> tuple[Document, Job]:
|
|
||||||
document = Document(
|
|
||||||
filename=Path(original_filename).name,
|
|
||||||
file_path=str(stored_path),
|
|
||||||
)
|
|
||||||
session.add(document)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
job = Job(
|
|
||||||
document_id=document.id,
|
|
||||||
status=JobStatus.QUEUED,
|
|
||||||
)
|
|
||||||
session.add(job)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(document)
|
|
||||||
await session.refresh(job)
|
|
||||||
return document, job
|
|
||||||
|
|
||||||
|
|
||||||
def _best_effort_delete(path: Path) -> None:
|
|
||||||
try:
|
|
||||||
if path.exists():
|
|
||||||
path.unlink()
|
|
||||||
except OSError:
|
|
||||||
logger.warning("Failed to clean up upload file after DB error: %s", path)
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -21,23 +23,26 @@ class JobService(ServiceBase):
|
|||||||
"""Create a new job in the database."""
|
"""Create a new job in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
_session.add(job)
|
_session.add(job)
|
||||||
await _session.commit()
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
await _session.refresh(job)
|
|
||||||
return job
|
return job
|
||||||
|
|
||||||
async def read_job(self, job_id: UUID, session: AsyncSession | None = None) -> Job:
|
async def read_job(self, job_id: UUID, session: AsyncSession | None = None) -> Job:
|
||||||
"""Read an existing job from the database.
|
"""Read an existing job from the database.
|
||||||
|
|
||||||
The selectinload option is used to eagerly load the related document for the job, which makes the full Document
|
The related document is always eagerly loaded so callers can safely
|
||||||
model object available in the return Job object.
|
access ``job.document`` in async contexts without triggering lazy-load IO.
|
||||||
"""
|
"""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
job = await _session.get(
|
query = (
|
||||||
Job,
|
select(Job)
|
||||||
job_id,
|
.options(
|
||||||
# Makes the full Document model object available in the return Job object
|
selectinload(Job.document), # pyright: ignore[reportArgumentType]
|
||||||
options=(selectinload(Job.document),), # pyright: ignore[reportArgumentType]
|
selectinload(Job.transcripts), # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
.where(Job.id == job_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
)
|
)
|
||||||
|
job = (await _session.exec(query)).first()
|
||||||
if job is None:
|
if job is None:
|
||||||
raise ValueError(f"Job with id {job_id} not found")
|
raise ValueError(f"Job with id {job_id} not found")
|
||||||
return job
|
return job
|
||||||
@@ -45,16 +50,15 @@ class JobService(ServiceBase):
|
|||||||
async def update_job(self, job: Job, session: AsyncSession | None = None) -> Job:
|
async def update_job(self, job: Job, session: AsyncSession | None = None) -> Job:
|
||||||
"""Update an existing job in the database."""
|
"""Update an existing job in the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
await _session.merge(job)
|
merged = await _session.merge(job)
|
||||||
await _session.commit()
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
await _session.refresh(job)
|
return merged
|
||||||
return job
|
|
||||||
|
|
||||||
async def delete_job(self, job: Job, session: AsyncSession | None = None) -> None:
|
async def delete_job(self, job: Job, session: AsyncSession | None = None) -> None:
|
||||||
"""Delete a job from the database."""
|
"""Delete a job from the database."""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
await _session.delete(job)
|
await _session.delete(job)
|
||||||
await _session.commit()
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
# Query Operations
|
# Query Operations
|
||||||
|
|
||||||
@@ -63,23 +67,30 @@ class JobService(ServiceBase):
|
|||||||
*,
|
*,
|
||||||
status: JobStatus | None = None,
|
status: JobStatus | None = None,
|
||||||
filename: str | None = None,
|
filename: str | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
) -> Sequence[Job]:
|
) -> Sequence[Job]:
|
||||||
"""Query jobs from the database based on provided filters."""
|
"""Query jobs from the database based on provided filters."""
|
||||||
async with self._session_scope(None) as session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Job)
|
query = select(Job).options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
||||||
if status is not None:
|
if status is not None:
|
||||||
query = query.where(Job.status == status)
|
query = query.where(Job.status == status)
|
||||||
if filename is not None:
|
if filename is not None:
|
||||||
query = query.where(Job.document.filename == filename)
|
query = query.where(Job.document.filename == filename)
|
||||||
return (await session.exec(query)).all()
|
result = await _session.exec(query)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
async def list_jobs(self, session: AsyncSession | None = None, *, load_docs: bool = False) -> Sequence[Job]:
|
async def list_jobs(
|
||||||
"""List all jobs in the database."""
|
self,
|
||||||
|
*,
|
||||||
|
load_docs: bool = False,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[Job]:
|
||||||
|
"""List all jobs in the database with eagerly loaded documents."""
|
||||||
|
_ = load_docs
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
query = select(Job)
|
query = select(Job).options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
||||||
if load_docs:
|
result = await _session.exec(query)
|
||||||
query = query.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
return result.all()
|
||||||
return (await _session.exec(query)).all()
|
|
||||||
|
|
||||||
# Other Operations
|
# Other Operations
|
||||||
|
|
||||||
@@ -90,11 +101,49 @@ class JobService(ServiceBase):
|
|||||||
session: AsyncSession | None = None,
|
session: AsyncSession | None = None,
|
||||||
) -> Job:
|
) -> Job:
|
||||||
"""Mark a job with a new status."""
|
"""Mark a job with a new status."""
|
||||||
|
return await self.update_job_state(job_id=job_id, status=status, session=session)
|
||||||
|
|
||||||
|
async def update_job_state(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job_id: UUID,
|
||||||
|
status: JobStatus,
|
||||||
|
retry_count_increment: int = 0,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Job:
|
||||||
|
"""Update a job's lifecycle fields.
|
||||||
|
|
||||||
|
When ``session`` is provided, this method flushes so callers can commit
|
||||||
|
once at an orchestration boundary.
|
||||||
|
"""
|
||||||
async with self._session_scope(session) as _session:
|
async with self._session_scope(session) as _session:
|
||||||
job = await _session.get(Job, job_id)
|
query = (
|
||||||
|
select(Job)
|
||||||
|
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
||||||
|
.where(Job.id == job_id)
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
job = (await _session.exec(query)).first()
|
||||||
if job is None:
|
if job is None:
|
||||||
raise ValueError(f"Job with id {job_id} not found")
|
raise ValueError(f"Job with id {job_id} not found")
|
||||||
job.status = status
|
job.status = status
|
||||||
await _session.commit()
|
if retry_count_increment:
|
||||||
await _session.refresh(job)
|
job.retry_count += retry_count_increment
|
||||||
|
job.updated_at = datetime.now(UTC)
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(job,))
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
async def read_next_queued_job(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Job | None:
|
||||||
|
"""Read the next queued job ordered by creation time."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = (
|
||||||
|
select(Job)
|
||||||
|
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
||||||
|
.where(Job.status == JobStatus.QUEUED)
|
||||||
|
.order_by(Job.created_at) # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
return (await _session.exec(query)).first()
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from ..config import Settings
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from ..config import get_settings
|
|
||||||
from ..errors import AppError
|
from transcription.config import Settings
|
||||||
from ..errors import ErrorCategory
|
from transcription.config import get_settings
|
||||||
|
from transcription.errors import AppError
|
||||||
|
from transcription.errors import ErrorCategory
|
||||||
|
|
||||||
|
from ..models import Document
|
||||||
|
from ..models import Job
|
||||||
|
from .documents import UploadJobResult
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -16,6 +24,73 @@ class UploadError(AppError):
|
|||||||
"""Raised when uploaded content cannot be persisted safely."""
|
"""Raised when uploaded content cannot be persisted safely."""
|
||||||
|
|
||||||
|
|
||||||
|
async def create_upload_job(
|
||||||
|
*,
|
||||||
|
filename: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
session: AsyncSession,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
) -> UploadJobResult:
|
||||||
|
"""Create upload-backed document and queued job records."""
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
|
stored_path = store_file(
|
||||||
|
filename=filename,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
settings=runtime_settings,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
document, job = await _create_upload_records(
|
||||||
|
session=session,
|
||||||
|
original_filename=filename,
|
||||||
|
stored_path=stored_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
_best_effort_delete(stored_path)
|
||||||
|
raise UploadError(
|
||||||
|
"Failed to create upload database records",
|
||||||
|
category=ErrorCategory.INFRA_TRANSIENT,
|
||||||
|
suggestion="Retry upload. If this keeps happening, verify database availability.",
|
||||||
|
retriable=True,
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
|
||||||
|
return UploadJobResult(
|
||||||
|
document_id=document.id,
|
||||||
|
job_id=job.id,
|
||||||
|
stored_path=stored_path,
|
||||||
|
original_filename=Path(filename).name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_upload_records(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
original_filename: str,
|
||||||
|
stored_path: Path,
|
||||||
|
) -> tuple[Document, Job]:
|
||||||
|
document = Document(
|
||||||
|
filename=Path(original_filename).name,
|
||||||
|
file_path=str(stored_path),
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
job = Job(document_id=document.id)
|
||||||
|
session.add(job)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(document)
|
||||||
|
await session.refresh(job)
|
||||||
|
return document, job
|
||||||
|
|
||||||
|
|
||||||
|
def _best_effort_delete(path: Path) -> None:
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Failed to clean up upload file after DB error: %s", path)
|
||||||
|
|
||||||
|
|
||||||
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
|
||||||
"""Persist an uploaded file to the configured upload directory."""
|
"""Persist an uploaded file to the configured upload directory."""
|
||||||
runtime_settings = settings or get_settings()
|
runtime_settings = settings or get_settings()
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
from collections.abc import Sequence
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from transcription.config import Settings
|
from transcription.config import Settings
|
||||||
@@ -55,18 +58,17 @@ class TranscriptionService(ServiceBase):
|
|||||||
super().__init__(session_factory=session_factory)
|
super().__init__(session_factory=session_factory)
|
||||||
self.provider = get_transcription_provider(settings=self.settings)
|
self.provider = get_transcription_provider(settings=self.settings)
|
||||||
|
|
||||||
async def create_transcript(self, transcript: Transcript) -> Transcript:
|
async def create_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
|
||||||
"""Create a new transcript in the database."""
|
"""Create a new transcript in the database."""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
session.add(transcript)
|
_session.add(transcript)
|
||||||
await session.commit()
|
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
||||||
await session.refresh(transcript)
|
|
||||||
return transcript
|
return transcript
|
||||||
|
|
||||||
async def read_transcript(self, transcript_id: UUID) -> Transcript:
|
async def read_transcript(self, transcript_id: UUID, *, session: AsyncSession | None = None) -> Transcript:
|
||||||
"""Read an existing transcript from the database."""
|
"""Read an existing transcript from the database."""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
transcript = await session.get(
|
transcript = await _session.get(
|
||||||
Transcript,
|
Transcript,
|
||||||
transcript_id,
|
transcript_id,
|
||||||
# Makes the full Job model object available in the return Transcript object
|
# Makes the full Job model object available in the return Transcript object
|
||||||
@@ -80,33 +82,142 @@ class TranscriptionService(ServiceBase):
|
|||||||
)
|
)
|
||||||
return transcript
|
return transcript
|
||||||
|
|
||||||
async def update_transcript(self, transcript: Transcript) -> Transcript:
|
async def update_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> Transcript:
|
||||||
"""Update an existing transcript in the database."""
|
"""Update an existing transcript in the database."""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
await session.merge(transcript)
|
merged = await _session.merge(transcript)
|
||||||
await session.commit()
|
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
|
||||||
await session.refresh(transcript)
|
return merged
|
||||||
return transcript
|
|
||||||
|
|
||||||
async def delete_transcript(self, transcript: Transcript) -> None:
|
async def delete_transcript(self, transcript: Transcript, *, session: AsyncSession | None = None) -> None:
|
||||||
"""Delete a transcript from the database."""
|
"""Delete a transcript from the database."""
|
||||||
async with self.session_factory() as session:
|
async with self._session_scope(session) as _session:
|
||||||
await session.delete(transcript)
|
await _session.delete(transcript)
|
||||||
await session.commit()
|
await self._finalize(session=_session, caller_session=session)
|
||||||
|
|
||||||
async def transcribe_document(
|
async def transcribe_document(
|
||||||
self,
|
self,
|
||||||
image_path: str | Path,
|
image_path: str | Path,
|
||||||
|
job_id: UUID,
|
||||||
*,
|
*,
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||||
) -> TranscriptionResult:
|
session: AsyncSession | None = None,
|
||||||
|
):
|
||||||
"""Transcribe a local image using the configured prompt and provider."""
|
"""Transcribe a local image using the configured prompt and provider."""
|
||||||
return transcribe_document_image(
|
result = await transcribe_document_image(
|
||||||
image_path=image_path,
|
image_path=image_path,
|
||||||
prompt_name=prompt_name,
|
prompt_name=prompt_name,
|
||||||
settings=self.settings,
|
settings=self.settings,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
)
|
)
|
||||||
|
await self.create_transcript_for_job(
|
||||||
|
job_id=job_id,
|
||||||
|
text=result.text,
|
||||||
|
provider=result.provider,
|
||||||
|
model=result.model,
|
||||||
|
prompt_name=result.prompt_name,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_transcript_for_job(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
job_id: UUID,
|
||||||
|
text: str | None,
|
||||||
|
error_detail: str | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Transcript:
|
||||||
|
"""Create a new transcript revision for a job id."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
rev_query = select(func.max(Transcript.revision)).where(Transcript.job_id == job_id)
|
||||||
|
rev_result = await _session.exec(rev_query)
|
||||||
|
max_revision = -1 if (rev := rev_result.one_or_none()) is None else rev
|
||||||
|
next_revision = max_revision + 1
|
||||||
|
|
||||||
|
transcript = Transcript(
|
||||||
|
job_id=job_id,
|
||||||
|
revision=next_revision,
|
||||||
|
provider=provider or self.settings.provider.value,
|
||||||
|
model=model or _resolve_transcript_model(provider=self.provider, settings=self.settings),
|
||||||
|
prompt_name=prompt_name,
|
||||||
|
text=text,
|
||||||
|
error_detail=error_detail,
|
||||||
|
)
|
||||||
|
_session.add(transcript)
|
||||||
|
await self._finalize(session=_session, caller_session=session, refresh=(transcript,))
|
||||||
|
return transcript
|
||||||
|
|
||||||
|
async def read_latest_transcript_by_job(
|
||||||
|
self,
|
||||||
|
job_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Transcript | None:
|
||||||
|
"""Read the latest transcript revision for a job id."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = _transcript_job_query(job_id=job_id).limit(1)
|
||||||
|
result = await _session.exec(query)
|
||||||
|
return result.one_or_none()
|
||||||
|
|
||||||
|
async def list_transcripts_by_job(
|
||||||
|
self,
|
||||||
|
job_id: UUID,
|
||||||
|
*,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Sequence[Transcript]:
|
||||||
|
"""List transcript revisions for a job id in ascending revision order."""
|
||||||
|
async with self._session_scope(session) as _session:
|
||||||
|
query = _transcript_job_query(job_id=job_id)
|
||||||
|
result = await _session.exec(query)
|
||||||
|
return result.all()
|
||||||
|
|
||||||
|
|
||||||
|
def _transcript_job_query(job_id: UUID):
|
||||||
|
return (
|
||||||
|
select(Transcript)
|
||||||
|
.where(Transcript.job_id == job_id)
|
||||||
|
.options(selectinload(Transcript.job)) # pyright: ignore[reportArgumentType]
|
||||||
|
.order_by(Transcript.revision) # pyright: ignore[reportArgumentType]
|
||||||
|
) # fmt: skip
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_transcript_model(*, provider: TranscriptionProvider, settings: Settings) -> str:
|
||||||
|
provider_model = getattr(provider, "model", None)
|
||||||
|
if isinstance(provider_model, str) and provider_model.strip():
|
||||||
|
return provider_model
|
||||||
|
|
||||||
|
if settings.provider_model and settings.provider_model.strip():
|
||||||
|
return settings.provider_model
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe_document_image(
|
||||||
|
image_path: str | Path,
|
||||||
|
*,
|
||||||
|
prompt_name: str = DEFAULT_PROMPT_FILE,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
provider: TranscriptionProvider | None = None,
|
||||||
|
) -> TranscriptionResult:
|
||||||
|
"""Transcribe a local image using the configured prompt and provider."""
|
||||||
|
runtime_settings = settings or get_settings()
|
||||||
|
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
|
||||||
|
image_bytes, mime_type = load_image_payload(image_path)
|
||||||
|
|
||||||
|
adapter = provider or get_transcription_provider(settings=runtime_settings)
|
||||||
|
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
|
||||||
|
|
||||||
|
with handle_transcription_errors():
|
||||||
|
result = await adapter.transcribe(
|
||||||
|
prompt_text=prompt_text,
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
mime_type=mime_type,
|
||||||
|
)
|
||||||
|
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
|
def load_prompt_text(*, prompt_name: str = DEFAULT_PROMPT_FILE, settings: Settings | None = None) -> str:
|
||||||
@@ -165,31 +276,6 @@ def load_image_payload(image_path: str | Path) -> tuple[bytes, str]:
|
|||||||
return path.read_bytes(), mime_type
|
return path.read_bytes(), mime_type
|
||||||
|
|
||||||
|
|
||||||
def transcribe_document_image(
|
|
||||||
image_path: str | Path,
|
|
||||||
*,
|
|
||||||
prompt_name: str = DEFAULT_PROMPT_FILE,
|
|
||||||
settings: Settings | None = None,
|
|
||||||
provider: TranscriptionProvider | None = None,
|
|
||||||
) -> TranscriptionResult:
|
|
||||||
"""Transcribe a local image using the configured prompt and provider."""
|
|
||||||
runtime_settings = settings or get_settings()
|
|
||||||
prompt_text = load_prompt_text(prompt_name=prompt_name, settings=runtime_settings)
|
|
||||||
image_bytes, mime_type = load_image_payload(image_path)
|
|
||||||
|
|
||||||
adapter = provider or get_transcription_provider(settings=runtime_settings)
|
|
||||||
logger.info("Starting transcription for image=%s mime_type=%s", image_path, mime_type)
|
|
||||||
|
|
||||||
with handle_transcription_errors():
|
|
||||||
result = adapter.transcribe(
|
|
||||||
prompt_text=prompt_text,
|
|
||||||
image_bytes=image_bytes,
|
|
||||||
mime_type=mime_type,
|
|
||||||
)
|
|
||||||
logger.info("Transcription completed for image=%s provider=%s", image_path, result.provider)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def handle_transcription_errors():
|
def handle_transcription_errors():
|
||||||
"""Context manager to handle transcription errors."""
|
"""Context manager to handle transcription errors."""
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from ..config import Settings
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..errors import AppError
|
||||||
|
from ..errors import classify_unexpected_error
|
||||||
|
from ..errors import format_error_detail
|
||||||
|
from ..models import Job
|
||||||
|
from ..models import JobStatus
|
||||||
|
from ..providers import TranscriptionResult
|
||||||
|
from . import ServiceBundle
|
||||||
|
from .transcription import DEFAULT_PROMPT_FILE
|
||||||
|
from .transcription import transcribe_document_image
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def advance_job(
|
||||||
|
job: Job,
|
||||||
|
services: ServiceBundle,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Job | None:
|
||||||
|
"""Advance a single job by lifecycle status."""
|
||||||
|
settings = settings or get_settings()
|
||||||
|
match job.status:
|
||||||
|
case JobStatus.QUEUED:
|
||||||
|
return await process_queued_job(job=job, services=services, session=session)
|
||||||
|
case JobStatus.FAILED:
|
||||||
|
if job.retry_count < settings.worker_max_retries:
|
||||||
|
return await services.jobs.update_job_state(
|
||||||
|
job_id=job.id,
|
||||||
|
status=JobStatus.QUEUED,
|
||||||
|
retry_count_increment=1,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"Job {job.id} has failed and reached max retries.")
|
||||||
|
return
|
||||||
|
case _:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
async def process_queued_job(
|
||||||
|
*,
|
||||||
|
job: Job,
|
||||||
|
services: ServiceBundle,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Job | None:
|
||||||
|
"""Process one complete transcription attempt for a queued job."""
|
||||||
|
if job.status != JobStatus.QUEUED:
|
||||||
|
logger.warning(f"Job {job.id} is not queued. Current status: {job.status}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Transaction A: claim job for processing.
|
||||||
|
if session is None:
|
||||||
|
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING)
|
||||||
|
else:
|
||||||
|
# If we're sharing the session, need to make sure setting the Job to PROCESSING is committed before we start
|
||||||
|
# the transcription, otherwise other workers may see the job as still QUEUED and try to process it.
|
||||||
|
job = await services.jobs.mark_job_status(job.id, JobStatus.PROCESSING, session=session)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
document = job.document
|
||||||
|
assert document is not None, (
|
||||||
|
f"Job {job.id} has no associated document or the document failed to be loaded by the job service."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await transcribe_document_image(document.file_path)
|
||||||
|
job = await _finalize_transcribed(job=job, services=services, result=result, session=session)
|
||||||
|
logger.info(
|
||||||
|
"Job transcribed operation=worker.process_job job_id=%s document_id=%s provider=%s",
|
||||||
|
job.id,
|
||||||
|
document.id,
|
||||||
|
result.provider,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
match exc:
|
||||||
|
case AppError() as error:
|
||||||
|
pass
|
||||||
|
case _:
|
||||||
|
error = classify_unexpected_error(exc, operation="worker.process_job")
|
||||||
|
|
||||||
|
job = await _finalize_failed(job=job, services=services, error=error, session=session)
|
||||||
|
logger.error(
|
||||||
|
"Job failed operation=worker.process_job job_id=%s document_id=%s error_id=%s category=%s",
|
||||||
|
job.id,
|
||||||
|
document.id,
|
||||||
|
error.error_id,
|
||||||
|
error.category.value,
|
||||||
|
)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
async def process_next_queued_job(
|
||||||
|
*,
|
||||||
|
services: ServiceBundle,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Process the next queued job if one exists."""
|
||||||
|
job = await services.jobs.read_next_queued_job(session=session)
|
||||||
|
if job is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await advance_job(job=job, services=services, settings=settings, session=session)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _finalize_transcribed(
|
||||||
|
*,
|
||||||
|
job: Job,
|
||||||
|
services: ServiceBundle,
|
||||||
|
result: TranscriptionResult,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Job:
|
||||||
|
"""Transaction B: transcript + TRANSCRIBED in one commit."""
|
||||||
|
if session is None:
|
||||||
|
async with services.jobs._session_scope() as local_session:
|
||||||
|
await services.transcriptions.create_transcript_for_job(
|
||||||
|
job_id=job.id,
|
||||||
|
text=result.text,
|
||||||
|
error_detail=None,
|
||||||
|
provider=result.provider,
|
||||||
|
model=result.model,
|
||||||
|
prompt_name=result.prompt_name,
|
||||||
|
session=local_session,
|
||||||
|
)
|
||||||
|
updated_job = await services.jobs.mark_job_status(
|
||||||
|
job.id,
|
||||||
|
JobStatus.TRANSCRIBED,
|
||||||
|
session=local_session,
|
||||||
|
)
|
||||||
|
await local_session.commit()
|
||||||
|
return updated_job
|
||||||
|
|
||||||
|
await services.transcriptions.create_transcript_for_job(
|
||||||
|
job_id=job.id,
|
||||||
|
text=result.text,
|
||||||
|
error_detail=None,
|
||||||
|
provider=result.provider,
|
||||||
|
model=result.model,
|
||||||
|
prompt_name=result.prompt_name,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
updated_job = await services.jobs.mark_job_status(
|
||||||
|
job.id,
|
||||||
|
JobStatus.TRANSCRIBED,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return updated_job
|
||||||
|
|
||||||
|
|
||||||
|
async def _finalize_retry(
|
||||||
|
*,
|
||||||
|
job: Job,
|
||||||
|
services: ServiceBundle,
|
||||||
|
error: AppError,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Job:
|
||||||
|
"""Transaction C: transcript error + QUEUED + retry increment in one commit."""
|
||||||
|
if session is None:
|
||||||
|
async with services.jobs._session_scope() as local_session:
|
||||||
|
await services.transcriptions.create_transcript_for_job(
|
||||||
|
job_id=job.id,
|
||||||
|
text=None,
|
||||||
|
error_detail=format_error_detail(error),
|
||||||
|
prompt_name=DEFAULT_PROMPT_FILE,
|
||||||
|
session=local_session,
|
||||||
|
)
|
||||||
|
updated_job = await services.jobs.update_job_state(
|
||||||
|
job_id=job.id,
|
||||||
|
status=JobStatus.QUEUED,
|
||||||
|
retry_count_increment=1,
|
||||||
|
session=local_session,
|
||||||
|
)
|
||||||
|
await local_session.commit()
|
||||||
|
else:
|
||||||
|
await services.transcriptions.create_transcript_for_job(
|
||||||
|
job_id=job.id,
|
||||||
|
text=None,
|
||||||
|
error_detail=format_error_detail(error),
|
||||||
|
prompt_name=DEFAULT_PROMPT_FILE,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
updated_job = await services.jobs.update_job_state(
|
||||||
|
job_id=job.id,
|
||||||
|
status=JobStatus.QUEUED,
|
||||||
|
retry_count_increment=1,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
if settings.worker_retry_backoff_seconds > 0:
|
||||||
|
await asyncio.sleep(settings.worker_retry_backoff_seconds)
|
||||||
|
return updated_job
|
||||||
|
|
||||||
|
|
||||||
|
async def _finalize_failed(
|
||||||
|
*,
|
||||||
|
job: Job,
|
||||||
|
services: ServiceBundle,
|
||||||
|
error: AppError,
|
||||||
|
session: AsyncSession | None = None,
|
||||||
|
) -> Job:
|
||||||
|
"""Transaction B: transcript error + FAILED in one commit."""
|
||||||
|
if session is None:
|
||||||
|
async with services.jobs._session_scope() as local_session:
|
||||||
|
await services.transcriptions.create_transcript_for_job(
|
||||||
|
job_id=job.id,
|
||||||
|
text=None,
|
||||||
|
error_detail=format_error_detail(error),
|
||||||
|
prompt_name=DEFAULT_PROMPT_FILE,
|
||||||
|
session=local_session,
|
||||||
|
)
|
||||||
|
updated_job = await services.jobs.mark_job_status(
|
||||||
|
job.id,
|
||||||
|
JobStatus.FAILED,
|
||||||
|
session=local_session,
|
||||||
|
)
|
||||||
|
await local_session.commit()
|
||||||
|
return updated_job
|
||||||
|
|
||||||
|
await services.transcriptions.create_transcript_for_job(
|
||||||
|
job_id=job.id,
|
||||||
|
text=None,
|
||||||
|
error_detail=format_error_detail(error),
|
||||||
|
prompt_name=DEFAULT_PROMPT_FILE,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
updated_job = await services.jobs.mark_job_status(
|
||||||
|
job.id,
|
||||||
|
JobStatus.FAILED,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return updated_job
|
||||||
@@ -1,14 +1,45 @@
|
|||||||
"""UI page registration exports."""
|
"""UI page registration exports."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from nicegui import app as nicegui_app
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
|
||||||
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
from transcription.ui.pages.upload_page import register_page as register_upload_page
|
||||||
|
|
||||||
|
_THEME_REGISTERED_STATE_KEY = "transcription_ui_theme_registered"
|
||||||
|
|
||||||
|
_THEME_COLORS: dict[str, str] = {
|
||||||
|
"primary": "#6f97e8",
|
||||||
|
"secondary": "#92b5f5",
|
||||||
|
"accent": "#7fc0de",
|
||||||
|
"dark": "#22304a",
|
||||||
|
"dark_page": "#1a2538",
|
||||||
|
"positive": "#86c8ad",
|
||||||
|
"negative": "#d98a9a",
|
||||||
|
"info": "#7ebdda",
|
||||||
|
"warning": "#e2c083",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _register_global_styles(app: FastAPI) -> None:
|
||||||
|
if getattr(app.state, _THEME_REGISTERED_STATE_KEY, False):
|
||||||
|
return
|
||||||
|
|
||||||
|
nicegui_app.colors(**_THEME_COLORS)
|
||||||
|
|
||||||
|
css_path = Path(__file__).resolve().parent / "static" / "colors.css"
|
||||||
|
if css_path.exists():
|
||||||
|
ui.add_css(css_path, shared=True)
|
||||||
|
|
||||||
|
setattr(app.state, _THEME_REGISTERED_STATE_KEY, True)
|
||||||
|
|
||||||
|
|
||||||
def register_pages(app: FastAPI) -> None:
|
def register_pages(app: FastAPI) -> None:
|
||||||
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
"""Register all NiceGUI pages and mount them onto the FastAPI app."""
|
||||||
|
_register_global_styles(app)
|
||||||
register_upload_page()
|
register_upload_page()
|
||||||
register_jobs_page()
|
register_jobs_page()
|
||||||
ui.run_with(app, mount_path="/ui", show_welcome_message=False)
|
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=True)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Reusable UI component exports."""
|
||||||
|
|
||||||
|
from transcription.ui.components.app_shell import NAV_ITEMS
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
|
|
||||||
|
__all__ = ["NAV_ITEMS", "render_document_panzoom", "render_navigation_header"]
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Reusable app shell primitives for page-level layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
|
||||||
|
("Upload", "/upload", "upload_file"),
|
||||||
|
("Jobs", "/jobs", "work_history"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_active_path(*, current_path: str, item_path: str) -> bool:
|
||||||
|
if item_path == "/jobs":
|
||||||
|
return current_path == "/jobs" or current_path.startswith("/jobs/")
|
||||||
|
return current_path == item_path
|
||||||
|
|
||||||
|
|
||||||
|
def _button_props(*, icon: str, is_active: bool) -> str:
|
||||||
|
if is_active:
|
||||||
|
return f"icon={icon} no-caps unelevated color=primary text-color=white"
|
||||||
|
return f"icon={icon} no-caps outline color=secondary text-color=secondary"
|
||||||
|
|
||||||
|
|
||||||
|
def _button_classes(*, is_active: bool) -> str:
|
||||||
|
base = "w-full sm:w-auto min-h-[40px] px-3 rounded-lg text-body2 text-weight-medium"
|
||||||
|
if is_active:
|
||||||
|
return f"{base}"
|
||||||
|
return f"{base}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_nav_button(*, label: str, path: str, icon: str, current_path: str) -> None:
|
||||||
|
is_active = _is_active_path(current_path=current_path, item_path=path)
|
||||||
|
button = ui.button(
|
||||||
|
label,
|
||||||
|
icon=icon,
|
||||||
|
on_click=lambda _=None, route=path: ui.navigate.to(route),
|
||||||
|
)
|
||||||
|
button.props(_button_props(icon=icon, is_active=is_active)).classes(_button_classes(is_active=is_active))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path(current_path: str | None) -> str:
|
||||||
|
normalized = (current_path or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
return "/upload"
|
||||||
|
return normalized.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def render_navigation_header(*, current_path: str | None = None) -> None:
|
||||||
|
"""Render a shared app header with links for top-level pages."""
|
||||||
|
normalized_path = _normalize_path(current_path)
|
||||||
|
|
||||||
|
with (
|
||||||
|
ui.header(elevated=True).props("bordered").classes("bg-dark text-white q-px-sm q-py-xs"),
|
||||||
|
ui.row().classes("w-full items-center justify-end q-gutter-xs"),
|
||||||
|
ui.element("div").classes("w-full grid grid-cols-2 gap-2 sm:flex sm:justify-start sm:gap-2"),
|
||||||
|
):
|
||||||
|
for label, path, icon in NAV_ITEMS:
|
||||||
|
_render_nav_button(label=label, path=path, icon=icon, current_path=normalized_path)
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Panzoom-backed document preview component."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.config import get_settings
|
||||||
|
from transcription.models import Document
|
||||||
|
|
||||||
|
PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.js"
|
||||||
|
UPLOADS_URL_PREFIX = "/uploads"
|
||||||
|
|
||||||
|
|
||||||
|
def render_document_panzoom(*, document: Document) -> None:
|
||||||
|
"""Render a document preview with pan and zoom interactions."""
|
||||||
|
_register_panzoom_assets()
|
||||||
|
|
||||||
|
host_id = f"document-panzoom-{uuid4().hex}"
|
||||||
|
document_url = _document_url(document)
|
||||||
|
document_kind = _document_kind(document)
|
||||||
|
|
||||||
|
with ui.card().classes("w-full q-pa-md"):
|
||||||
|
with ui.row().classes("w-full items-center justify-between no-wrap"):
|
||||||
|
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(document.filename).classes("text-caption text-grey-4 ellipsis").style(
|
||||||
|
"max-width: 60%; text-align: right;"
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
ui.element("div").classes("w-full document-panzoom-host rounded-borders q-mt-md")
|
||||||
|
# .style(f"height: {height};")
|
||||||
|
) as host:
|
||||||
|
host.props(f"id={host_id}")
|
||||||
|
with ui.element("div").classes("document-panzoom-surface"):
|
||||||
|
if document_kind == "pdf":
|
||||||
|
ui.html(
|
||||||
|
f'<iframe class="document-panzoom-iframe" '
|
||||||
|
f'src="{document_url}" title="{document.filename}" '
|
||||||
|
"data-panzoom-target></iframe>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ui.html(
|
||||||
|
f'<img class="document-panzoom-media" '
|
||||||
|
f'src="{document_url}" alt="{document.filename}" '
|
||||||
|
"data-panzoom-target data-panzoom-media />"
|
||||||
|
)
|
||||||
|
|
||||||
|
_attach_panzoom(host_id)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _register_panzoom_assets() -> None:
|
||||||
|
ui.add_head_html(
|
||||||
|
f'<script src="{PANGOZOOM_CDN_URL}"></script>',
|
||||||
|
shared=True,
|
||||||
|
)
|
||||||
|
ui.add_head_html(
|
||||||
|
"""
|
||||||
|
<style>
|
||||||
|
.document-panzoom-host {
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-panzoom-surface {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-panzoom-media {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-panzoom-iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
""",
|
||||||
|
shared=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _document_url(document: Document) -> str:
|
||||||
|
file_path = Path(document.file_path)
|
||||||
|
upload_dir = get_settings().upload_dir
|
||||||
|
|
||||||
|
relative_path: Path
|
||||||
|
try:
|
||||||
|
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
parts = file_path.parts
|
||||||
|
if "uploads" in parts:
|
||||||
|
uploads_index = parts.index("uploads")
|
||||||
|
relative_path = Path(*parts[uploads_index + 1 :])
|
||||||
|
else:
|
||||||
|
relative_path = Path(file_path.name)
|
||||||
|
|
||||||
|
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
|
||||||
|
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _document_kind(document: Document) -> str:
|
||||||
|
suffix = Path(document.file_path).suffix.lower()
|
||||||
|
if suffix == ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
return "image"
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_panzoom(host_id: str) -> None:
|
||||||
|
ui.run_javascript(
|
||||||
|
f"""
|
||||||
|
(function() {{
|
||||||
|
if (!window.Panzoom) return;
|
||||||
|
window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
|
||||||
|
const host = document.getElementById({host_id!r});
|
||||||
|
if (!host) return;
|
||||||
|
const target = host.querySelector('[data-panzoom-target]');
|
||||||
|
const media = host.querySelector('[data-panzoom-media]');
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const cleanup = () => {{
|
||||||
|
const existing = window.__transcriptionPanzoom[{host_id!r}];
|
||||||
|
if (existing?.resizeObserver) existing.resizeObserver.disconnect();
|
||||||
|
if (existing?.wheelHandler) host.removeEventListener('wheel', existing.wheelHandler);
|
||||||
|
if (existing?.instance) existing.instance.destroy();
|
||||||
|
}};
|
||||||
|
|
||||||
|
const computeFitScale = () => {{
|
||||||
|
const hostRect = host.getBoundingClientRect();
|
||||||
|
if (hostRect.width <= 0 || hostRect.height <= 0) return null;
|
||||||
|
return 1;
|
||||||
|
}};
|
||||||
|
|
||||||
|
const buildInstance = () => {{
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const fitScale = computeFitScale();
|
||||||
|
if (fitScale === null) return false;
|
||||||
|
|
||||||
|
const minScale = Math.min(fitScale, 0.01);
|
||||||
|
const instance = Panzoom(target, {{
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
startScale: fitScale,
|
||||||
|
minScale: minScale,
|
||||||
|
maxScale: 256,
|
||||||
|
step: 0.2,
|
||||||
|
roundPixels: false,
|
||||||
|
panOnlyWhenZoomed: true,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}});
|
||||||
|
|
||||||
|
const wheelHandler = (event) => instance.zoomWithWheel(event);
|
||||||
|
host.addEventListener('wheel', wheelHandler, {{ passive: false }});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {{
|
||||||
|
instance.reset({{ animate: false }});
|
||||||
|
}});
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {{
|
||||||
|
const nextFitScale = computeFitScale();
|
||||||
|
if (nextFitScale === null) return;
|
||||||
|
instance.setOptions({{
|
||||||
|
startScale: nextFitScale,
|
||||||
|
minScale: Math.min(nextFitScale, 0.01),
|
||||||
|
}});
|
||||||
|
instance.reset({{ animate: false }});
|
||||||
|
}});
|
||||||
|
resizeObserver.observe(host);
|
||||||
|
|
||||||
|
window.__transcriptionPanzoom[{host_id!r}] = {{
|
||||||
|
instance,
|
||||||
|
wheelHandler,
|
||||||
|
resizeObserver,
|
||||||
|
}};
|
||||||
|
return true;
|
||||||
|
}};
|
||||||
|
|
||||||
|
const initWhenReady = (retries = 15) => {{
|
||||||
|
if (buildInstance()) return;
|
||||||
|
if (retries <= 0) return;
|
||||||
|
requestAnimationFrame(() => initWhenReady(retries - 1));
|
||||||
|
}};
|
||||||
|
|
||||||
|
if (media && media.tagName === 'IMG' && !media.complete) {{
|
||||||
|
media.addEventListener('load', () => initWhenReady(), {{ once: true }});
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
initWhenReady();
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -2,29 +2,102 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.models import Document
|
from transcription.models import Document
|
||||||
from transcription.models import Job
|
from transcription.models import Job
|
||||||
from transcription.models import Transcript
|
from transcription.models import Transcript
|
||||||
|
from transcription.ui.components.document_panzoom import render_document_panzoom
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def render_job_detail(*, job: Job, document: Document | None, transcript: Transcript | None) -> None:
|
def _status_chip_classes(status: str) -> str:
|
||||||
|
if status == "queued":
|
||||||
|
return "bg-blue-1 text-blue-10"
|
||||||
|
if status == "processing":
|
||||||
|
return "bg-amber-1 text-amber-10"
|
||||||
|
if status == "transcribed":
|
||||||
|
return "bg-green-1 text-green-10"
|
||||||
|
if status == "failed":
|
||||||
|
return "bg-red-1 text-red-10"
|
||||||
|
return "bg-grey-2 text-grey-9"
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_row(label: str, value: str) -> None:
|
||||||
|
with ui.row().classes("w-full items-start justify-between q-gutter-x-md"):
|
||||||
|
ui.label(label).classes("text-caption text-grey-5 text-uppercase w-28")
|
||||||
|
ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_document_section(document: Document) -> None:
|
||||||
|
with ui.card().classes("w-full q-pa-md"):
|
||||||
|
ui.label("Document").classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.separator().classes("q-my-sm")
|
||||||
|
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||||
|
_metadata_row("Filename", document.filename)
|
||||||
|
_metadata_row("File path", document.file_path)
|
||||||
|
|
||||||
|
ui.separator().classes("q-my-md")
|
||||||
|
render_document_panzoom(document=document)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_transcript_section(transcripts: Sequence[Transcript]) -> None:
|
||||||
|
with ui.card().classes("w-full q-pa-md"):
|
||||||
|
ui.label("Transcripts").classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.separator().classes("q-my-sm")
|
||||||
|
|
||||||
|
if not transcripts:
|
||||||
|
ui.label("Transcript history is not available yet.").classes("text-body2 text-grey-3")
|
||||||
|
return
|
||||||
|
|
||||||
|
for transcript in transcripts:
|
||||||
|
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||||
|
_metadata_row("Revision", str(transcript.revision))
|
||||||
|
_metadata_row("Provider", transcript.provider)
|
||||||
|
_metadata_row("Prompt", transcript.prompt_name)
|
||||||
|
_metadata_row("Created", transcript.created_at.isoformat())
|
||||||
|
|
||||||
|
if transcript.text:
|
||||||
|
ui.separator().classes("q-my-sm")
|
||||||
|
with ui.card().classes("w-fullq-pa-sm"):
|
||||||
|
ui.markdown(transcript.text).classes("text-grey-1")
|
||||||
|
elif transcript.error_detail:
|
||||||
|
ui.separator().classes("q-my-sm")
|
||||||
|
with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"):
|
||||||
|
ui.label("Failure detail").classes("text-caption text-uppercase")
|
||||||
|
ui.label(transcript.error_detail).classes("text-body2")
|
||||||
|
|
||||||
|
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||||
|
|
||||||
|
|
||||||
|
def render_job_detail(*, job: Job, document: Document | None, transcripts: Sequence[Transcript]) -> None:
|
||||||
"""Render all sections for the job detail page."""
|
"""Render all sections for the job detail page."""
|
||||||
ui.label(f"Job ID: {job.id}")
|
logger.debug("Rendering job detail for job ID %s with %d transcripts", job.id, len(transcripts))
|
||||||
ui.label(f"Status: {job.status.value}")
|
status_text = job.status.value
|
||||||
ui.label(f"Created: {job.created_at.isoformat()}")
|
with ui.column().classes("w-full max-w-4xl q-gutter-md"):
|
||||||
ui.label(f"Updated: {job.updated_at.isoformat()}")
|
with ui.card().classes("w-full q-pa-lg"):
|
||||||
|
with ui.row().classes("w-full items-center justify-between q-gutter-md"):
|
||||||
|
with ui.column().classes("q-gutter-none"):
|
||||||
|
ui.label("Job overview").classes("text-h6 text-weight-bold")
|
||||||
|
ui.label(str(job.id)).classes("text-caption text-grey-5")
|
||||||
|
status_chip_classes = (
|
||||||
|
"q-px-sm q-py-xs rounded-borders "
|
||||||
|
"text-weight-medium text-capitalize "
|
||||||
|
f"{_status_chip_classes(status_text)}"
|
||||||
|
)
|
||||||
|
ui.label(status_text).classes(status_chip_classes)
|
||||||
|
|
||||||
if document is not None:
|
ui.separator().classes("q-my-md bg-blue-grey-7")
|
||||||
ui.label(f"Filename: {document.filename}")
|
with ui.column().classes("w-full q-gutter-y-xs"):
|
||||||
ui.label(f"File path: {document.file_path}")
|
_metadata_row("Created", job.created_at.isoformat())
|
||||||
|
_metadata_row("Updated", job.updated_at.isoformat())
|
||||||
|
_metadata_row("Retries", str(job.retry_count))
|
||||||
|
|
||||||
if transcript is None:
|
if document is not None:
|
||||||
ui.label("Transcript not available yet.")
|
_render_document_section(document)
|
||||||
elif transcript.text:
|
|
||||||
ui.label("Transcript:")
|
_render_transcript_section(transcripts)
|
||||||
ui.markdown(transcript.text)
|
|
||||||
elif transcript.error_detail:
|
|
||||||
ui.label("Failure detail:")
|
|
||||||
ui.label(transcript.error_detail)
|
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Reusable jobs table rendering helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from nicegui import ui
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class JobTableRow:
|
|
||||||
"""Read model consumed by the shared jobs table component."""
|
|
||||||
|
|
||||||
id: UUID
|
|
||||||
status: str
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, str]]:
|
|
||||||
"""Convert typed rows into table-compatible dictionaries."""
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": str(row.id),
|
|
||||||
"status": row.status,
|
|
||||||
"created_at": row.created_at,
|
|
||||||
"updated_at": row.updated_at,
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
|
||||||
"""Render jobs table and per-row detail links."""
|
|
||||||
if not rows:
|
|
||||||
ui.label("No jobs yet.")
|
|
||||||
return
|
|
||||||
|
|
||||||
serialized_rows = _serialize_rows(rows)
|
|
||||||
ui.table(
|
|
||||||
columns=[
|
|
||||||
{"name": "id", "label": "Job ID", "field": "id"},
|
|
||||||
{"name": "status", "label": "Status", "field": "status"},
|
|
||||||
{"name": "created_at", "label": "Created", "field": "created_at"},
|
|
||||||
{"name": "updated_at", "label": "Updated", "field": "updated_at"},
|
|
||||||
],
|
|
||||||
rows=serialized_rows,
|
|
||||||
row_key="id",
|
|
||||||
).classes("w-full")
|
|
||||||
|
|
||||||
with ui.column().classes("gap-1"):
|
|
||||||
for row in serialized_rows:
|
|
||||||
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .jobs import JobTableRow
|
||||||
|
from .jobs import render_jobs_table
|
||||||
|
|
||||||
|
__all__ = ["JobTableRow", "render_jobs_table"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Common logic for generating table widgets."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nicegui import events
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_row_id(args: Any) -> str | None:
|
||||||
|
if isinstance(args, dict):
|
||||||
|
if isinstance(args.get("row"), dict):
|
||||||
|
row_id = args["row"].get("id")
|
||||||
|
return str(row_id) if row_id is not None else None
|
||||||
|
row_id = args.get("id")
|
||||||
|
return str(row_id) if row_id is not None else None
|
||||||
|
|
||||||
|
if isinstance(args, list):
|
||||||
|
for value in args:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
row_id = value.get("id")
|
||||||
|
if row_id is not None:
|
||||||
|
return str(row_id)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bind_row_click_handler(
|
||||||
|
table: Any,
|
||||||
|
*,
|
||||||
|
on_row_click_id: Callable[[str], None],
|
||||||
|
) -> None:
|
||||||
|
def handle_row_click(event: events.GenericEventArguments) -> None:
|
||||||
|
row_id = _extract_row_id(event.args)
|
||||||
|
if row_id is None:
|
||||||
|
return
|
||||||
|
on_row_click_id(row_id)
|
||||||
|
|
||||||
|
table.on("rowClick", handle_row_click)
|
||||||
|
logger.debug("Row click handler bound to table")
|
||||||
|
|
||||||
|
|
||||||
|
def build_table(
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
columns: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
default_sort_by: str | None = None,
|
||||||
|
default_descending: bool = False,
|
||||||
|
classes: str = "app-table",
|
||||||
|
on_row_click_id: Callable[[str], None] | None = None,
|
||||||
|
) -> Any:
|
||||||
|
pagination: dict[str, Any] = {"rowsPerPage": 25}
|
||||||
|
if default_sort_by is not None:
|
||||||
|
pagination["sortBy"] = default_sort_by
|
||||||
|
pagination["descending"] = default_descending
|
||||||
|
|
||||||
|
table = (
|
||||||
|
ui.table(
|
||||||
|
rows=rows,
|
||||||
|
columns=columns,
|
||||||
|
row_key="id",
|
||||||
|
pagination=pagination,
|
||||||
|
)
|
||||||
|
.classes(classes)
|
||||||
|
.props('table-style="table-layout: fixed; width: 100%;"')
|
||||||
|
)
|
||||||
|
logger.info("Table built with %d rows and %d columns", len(rows), len(columns))
|
||||||
|
if on_row_click_id is not None:
|
||||||
|
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
|
||||||
|
return table
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Jobs table rendering helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from .common import build_table
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class JobTableRow:
|
||||||
|
"""Read model consumed by the jobs table component."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
status: str
|
||||||
|
filename: str
|
||||||
|
retry_count: int
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
def _format_timestamp(value: str) -> str:
|
||||||
|
"""Return a friendly UTC timestamp for table display."""
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
parsed = parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
|
||||||
|
return parsed.astimezone().strftime("%b %d, %I:%M %p")
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(row.id),
|
||||||
|
"status": row.status,
|
||||||
|
"filename": row.filename,
|
||||||
|
"retry_count": row.retry_count,
|
||||||
|
"created_at": _format_timestamp(row.created_at),
|
||||||
|
"updated_at": _format_timestamp(row.updated_at),
|
||||||
|
"created_sort": row.created_at,
|
||||||
|
"updated_sort": row.updated_at,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
|
||||||
|
"""Render jobs table and open a detail page when clicking a row."""
|
||||||
|
if not rows:
|
||||||
|
ui.label("No jobs yet.")
|
||||||
|
return
|
||||||
|
|
||||||
|
build_table(
|
||||||
|
rows=_serialize_rows(rows),
|
||||||
|
columns=[
|
||||||
|
{"name": "id", "label": "Job ID", "field": "id", "sortable": True},
|
||||||
|
{"name": "status", "label": "Status", "field": "status", "sortable": True},
|
||||||
|
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True},
|
||||||
|
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
|
||||||
|
{"name": "created_at", "label": "Created", "field": "created_at", "sortable": True},
|
||||||
|
{"name": "updated_at", "label": "Updated", "field": "updated_at", "sortable": True},
|
||||||
|
],
|
||||||
|
default_sort_by="created_sort",
|
||||||
|
default_descending=True,
|
||||||
|
classes="app-table w-full",
|
||||||
|
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Reusable transcript UI components."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
from transcription.models import Transcript
|
||||||
|
|
||||||
|
type TranscriptAction = Callable[[Transcript], Awaitable[None] | None]
|
||||||
|
|
||||||
|
|
||||||
|
def render_transcript_revision_row(
|
||||||
|
*,
|
||||||
|
transcript: Transcript,
|
||||||
|
initially_expanded: bool = False,
|
||||||
|
classes: str = "w-full",
|
||||||
|
on_delete: TranscriptAction | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Render one collapsible row for a single transcript revision."""
|
||||||
|
status_label = "Failed" if transcript.error_detail else "Transcribed"
|
||||||
|
header = f"Revision {transcript.revision} | {status_label}"
|
||||||
|
caption = f"{transcript.provider} | {transcript.model} | {_format_created_at(transcript.created_at)}"
|
||||||
|
|
||||||
|
expansion = ui.expansion(value=initially_expanded, group="group").classes(
|
||||||
|
f"{classes} rounded-borders bg-blue-grey-10"
|
||||||
|
)
|
||||||
|
|
||||||
|
with expansion, ui.column().classes("w-full q-gutter-y-sm q-pa-sm"):
|
||||||
|
with expansion.add_slot("header"), ui.row().classes("w-full items-start justify-between q-gutter-md"):
|
||||||
|
with ui.column().classes("q-gutter-none"):
|
||||||
|
ui.label(header).classes("text-subtitle1 text-weight-medium")
|
||||||
|
ui.label(caption).classes("text-caption text-grey-5")
|
||||||
|
|
||||||
|
if on_delete is not None:
|
||||||
|
with ui.dialog() as delete_dialog, ui.card().classes("q-pa-md"):
|
||||||
|
ui.label("Delete this transcript revision?").classes("text-body1")
|
||||||
|
with ui.row().classes("w-full justify-end q-gutter-sm"):
|
||||||
|
ui.button("Cancel", on_click=lambda: delete_dialog.submit(False)).props("flat")
|
||||||
|
ui.button("Delete", on_click=lambda: delete_dialog.submit(True)).props(
|
||||||
|
'unelevated color="negative"'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_current_transcript() -> None:
|
||||||
|
delete_dialog.open()
|
||||||
|
confirmed = await delete_dialog
|
||||||
|
if not confirmed:
|
||||||
|
return
|
||||||
|
|
||||||
|
maybe_awaitable = on_delete(transcript)
|
||||||
|
if isinstance(maybe_awaitable, Awaitable):
|
||||||
|
await maybe_awaitable
|
||||||
|
|
||||||
|
with ui.column(align_items="center").classes("self-center q-gutter-none"):
|
||||||
|
ui.button(icon="delete", on_click=delete_current_transcript).props(
|
||||||
|
'flat round dense color="negative"'
|
||||||
|
)
|
||||||
|
_metadata_row(label="Provider", value=transcript.provider)
|
||||||
|
_metadata_row(label="Model", value=transcript.model)
|
||||||
|
_metadata_row(label="Created", value=_format_created_at(transcript.created_at))
|
||||||
|
|
||||||
|
if transcript.text:
|
||||||
|
with ui.card().classes("w-full q-pa-sm"):
|
||||||
|
ui.markdown(transcript.text)
|
||||||
|
|
||||||
|
if transcript.error_detail:
|
||||||
|
with ui.card().classes("w-full bg-red-1 text-red-10 q-pa-sm"):
|
||||||
|
ui.label("Failure detail").classes("text-caption text-uppercase")
|
||||||
|
ui.label(transcript.error_detail).classes("text-body2")
|
||||||
|
|
||||||
|
return expansion
|
||||||
|
|
||||||
|
|
||||||
|
def _format_created_at(value: datetime) -> str:
|
||||||
|
"""Return a compact UTC-like timestamp for row captions."""
|
||||||
|
return value.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _metadata_row(*, label: str, value: str) -> None:
|
||||||
|
with ui.row().classes("w-md items-start justify-between q-gutter-x-md"):
|
||||||
|
ui.label(label).classes("text-caption text-grey-5 text-uppercase")
|
||||||
|
ui.label(value).classes("text-body2 text-right text-grey-1 break-all")
|
||||||
@@ -9,11 +9,11 @@ from nicegui import ui
|
|||||||
from nicegui.binding import bindable_dataclass
|
from nicegui.binding import bindable_dataclass
|
||||||
from nicegui.events import UploadEventArguments
|
from nicegui.events import UploadEventArguments
|
||||||
|
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.errors import AppError
|
||||||
from transcription.services.documents import UploadError
|
|
||||||
from transcription.services.documents import UploadJobResult
|
from transcription.services.documents import UploadJobResult
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.error_presenter import summarize_error
|
from transcription.ui.components.error_presenter import summarize_error
|
||||||
|
from transcription.worker import WorkerNotifier
|
||||||
|
|
||||||
type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]]
|
type UploadSubmitter = Callable[[str, bytes], Awaitable[UploadJobResult]]
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class UploadWidgetState:
|
|||||||
message: str = ""
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
def render_upload_widget(*, service: DocumentService) -> None:
|
def render_upload_widget(*, submitter: UploadSubmitter, notifier: WorkerNotifier | None = None) -> None:
|
||||||
"""Render upload controls and common status/error handling."""
|
"""Render upload controls and common status/error handling."""
|
||||||
state = UploadWidgetState()
|
state = UploadWidgetState()
|
||||||
status_label = ui.label("Upload a document to start transcription.")
|
status_label = ui.label("Upload a document to start transcription.")
|
||||||
@@ -41,12 +41,14 @@ def render_upload_widget(*, service: DocumentService) -> None:
|
|||||||
status_label.text = "Uploading..."
|
status_label.text = "Uploading..."
|
||||||
try:
|
try:
|
||||||
payload = await event.file.read()
|
payload = await event.file.read()
|
||||||
result = await service.upload_file(filename=event.file.name, file_bytes=payload)
|
result = await submitter(event.file.name, payload)
|
||||||
job_id = result.job_id
|
job_id = result.job_id
|
||||||
state.message = f"Created job {job_id}" if job_id is not None else "Upload complete"
|
state.message = f"Created job {job_id}" if job_id is not None else "Upload complete"
|
||||||
status_label.text = state.message
|
status_label.text = state.message
|
||||||
|
if notifier is not None:
|
||||||
|
notifier.notify()
|
||||||
ui.notify(state.message, type="positive")
|
ui.notify(state.message, type="positive")
|
||||||
except UploadError as exc:
|
except AppError as exc:
|
||||||
state.message = summarize_error(exc, operation="upload.submit")
|
state.message = summarize_error(exc, operation="upload.submit")
|
||||||
status_label.text = f"Upload failed: {state.message}"
|
status_label.text = f"Upload failed: {state.message}"
|
||||||
show_error(exc, title="Upload failed", operation="upload.submit")
|
show_error(exc, title="Upload failed", operation="upload.submit")
|
||||||
|
|||||||
@@ -4,89 +4,96 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
from sqlmodel import desc
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from transcription.db import get_session
|
from transcription.app_state import resolve_session_factory
|
||||||
from transcription.models import Document
|
from transcription.models import JobStatus
|
||||||
from transcription.models import Job
|
from transcription.services.jobs import JobService
|
||||||
from transcription.models import Transcript
|
from transcription.services.transcription import TranscriptionService
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.error_presenter import show_error
|
from transcription.ui.components.error_presenter import show_error
|
||||||
from transcription.ui.components.error_presenter import summarize_error
|
from transcription.ui.components.table.jobs import render_jobs_table
|
||||||
from transcription.ui.components.job_detail import render_job_detail
|
|
||||||
from transcription.ui.components.job_table import JobTableRow
|
|
||||||
from transcription.ui.components.job_table import render_jobs_table
|
|
||||||
|
|
||||||
|
from ..components.document_panzoom import render_document_panzoom
|
||||||
async def fetch_jobs() -> list[JobTableRow]:
|
from ..components.table.jobs import JobTableRow
|
||||||
"""Return jobs for display in most-recent-first order."""
|
from ..components.transcript import render_transcript_revision_row
|
||||||
async with get_session() as session:
|
|
||||||
jobs = (await session.exec(select(Job).order_by(desc(Job.created_at)))).all()
|
|
||||||
return [
|
|
||||||
JobTableRow(
|
|
||||||
id=job.id,
|
|
||||||
status=job.status.value,
|
|
||||||
created_at=job.created_at.isoformat(),
|
|
||||||
updated_at=job.updated_at.isoformat(),
|
|
||||||
)
|
|
||||||
for job in jobs
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_job_detail(job_id: UUID) -> tuple[Job | None, Document | None, Transcript | None]:
|
|
||||||
"""Return job, document, and transcript for detail view."""
|
|
||||||
async with get_session() as session:
|
|
||||||
job = await session.get(Job, job_id)
|
|
||||||
if job is None:
|
|
||||||
return None, None, None
|
|
||||||
document = await session.get(Document, job.document_id)
|
|
||||||
transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job.id))).first()
|
|
||||||
return job, document, transcript
|
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
def register_page() -> None:
|
||||||
"""Register jobs list and detail routes."""
|
"""Register jobs list and detail routes."""
|
||||||
|
|
||||||
@ui.page("/jobs")
|
@ui.page("/jobs")
|
||||||
async def jobs_page() -> None:
|
async def jobs_page(request: Request) -> None:
|
||||||
ui.label("Transcription Jobs")
|
session_factory = resolve_session_factory(request.app.state)
|
||||||
status = ui.label("Ready")
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
|
render_navigation_header(current_path="/jobs")
|
||||||
|
|
||||||
@ui.refreshable
|
@ui.refreshable
|
||||||
async def render_table() -> None:
|
async def render_table() -> None:
|
||||||
jobs = await fetch_jobs()
|
jobs = [
|
||||||
|
JobTableRow(
|
||||||
|
id=job.id,
|
||||||
|
status=job.status.value,
|
||||||
|
filename=job.filename,
|
||||||
|
retry_count=job.retry_count,
|
||||||
|
created_at=job.created_at.isoformat(),
|
||||||
|
updated_at=job.updated_at.isoformat(),
|
||||||
|
)
|
||||||
|
for job in await jobs_service.list_jobs()
|
||||||
|
]
|
||||||
render_jobs_table(jobs)
|
render_jobs_table(jobs)
|
||||||
|
|
||||||
async def refresh() -> None:
|
ui.button("Refresh", on_click=render_table.refresh, icon="refresh")
|
||||||
status.text = "Refreshing..."
|
|
||||||
try:
|
|
||||||
await render_table.refresh()
|
|
||||||
status.text = "Refreshed"
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
status.text = f"Refresh failed: {summarize_error(exc, operation='jobs.refresh')}"
|
|
||||||
show_error(exc, title="Jobs refresh failed", operation="jobs.refresh")
|
|
||||||
|
|
||||||
ui.button("Refresh", on_click=refresh)
|
|
||||||
await render_table()
|
await render_table()
|
||||||
ui.link("Back to upload", "/")
|
|
||||||
|
|
||||||
@ui.page("/jobs/{job_id}")
|
@ui.page("/jobs/{job_id}")
|
||||||
async def job_detail_page(job_id: str) -> None:
|
async def job_detail_page(job_id: str, request: Request) -> None:
|
||||||
ui.label("Job Detail")
|
session_factory = resolve_session_factory(request.app.state)
|
||||||
try:
|
jobs_service = JobService(session_factory=session_factory)
|
||||||
parsed_id = UUID(job_id)
|
transcription_service = TranscriptionService(session_factory=session_factory)
|
||||||
except ValueError:
|
render_navigation_header(current_path="/jobs")
|
||||||
ui.label("Invalid job id")
|
|
||||||
ui.link("Back to jobs", "/jobs")
|
|
||||||
return
|
|
||||||
|
|
||||||
job, document, transcript = await fetch_job_detail(parsed_id)
|
job = await jobs_service.read_job(job_id=UUID(job_id))
|
||||||
if job is None:
|
|
||||||
ui.label("Job not found")
|
|
||||||
ui.link("Back to jobs", "/jobs")
|
|
||||||
return
|
|
||||||
|
|
||||||
render_job_detail(job=job, document=document, transcript=transcript)
|
with ui.splitter(value=30).classes("w-full h-[calc(100vh-64px)]") as splitter:
|
||||||
|
with splitter.before, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
|
||||||
|
render_document_panzoom(document=job.document)
|
||||||
|
with splitter.after, ui.column(align_items="stretch").classes("w-full h-full p-4 gap-3"):
|
||||||
|
with ui.row():
|
||||||
|
ui.button(icon="arrow_back", on_click=ui.navigate.back)
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label(f"{job.id}").classes("text-h6 text-weight-bold")
|
||||||
|
match job.status:
|
||||||
|
case JobStatus.TRANSCRIBED:
|
||||||
|
ui.chip(job.status.value.upper(), color="green", text_color="white").props("outline")
|
||||||
|
case _:
|
||||||
|
ui.label(f"{job.status.value}").classes("text-subtitle1 text-weight-medium")
|
||||||
|
|
||||||
ui.link("Back to jobs", "/jobs")
|
async def delete_transcript_by_id(transcript_id: UUID, revision: int) -> None:
|
||||||
|
try:
|
||||||
|
transcript = await transcription_service.read_transcript(transcript_id=transcript_id)
|
||||||
|
await transcription_service.delete_transcript(transcript)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
show_error(exc, title="Delete failed", operation="jobs.delete_transcript")
|
||||||
|
return
|
||||||
|
|
||||||
|
ui.notify(f"Deleted revision {revision}", type="positive")
|
||||||
|
await render_transcript_list.refresh()
|
||||||
|
|
||||||
|
@ui.refreshable
|
||||||
|
async def render_transcript_list() -> None:
|
||||||
|
refreshed_job = await jobs_service.read_job(job_id=UUID(job_id))
|
||||||
|
for i, transcript in enumerate(refreshed_job.transcripts):
|
||||||
|
render_transcript_revision_row(
|
||||||
|
transcript=transcript,
|
||||||
|
initially_expanded=(i == 0),
|
||||||
|
on_delete=(
|
||||||
|
lambda _transcript, tid=transcript.id, rev=transcript.revision: delete_transcript_by_id(
|
||||||
|
tid,
|
||||||
|
rev,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await render_transcript_list()
|
||||||
|
|||||||
@@ -2,20 +2,32 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from transcription.services.documents import DocumentService
|
from transcription.app_state import resolve_session_factory
|
||||||
|
from transcription.db import get_session
|
||||||
|
from transcription.services.store import create_upload_job
|
||||||
|
from transcription.ui.components.app_shell import render_navigation_header
|
||||||
from transcription.ui.components.upload import render_upload_widget
|
from transcription.ui.components.upload import render_upload_widget
|
||||||
|
from transcription.worker import resolve_worker_notifier
|
||||||
|
|
||||||
|
|
||||||
def register_page() -> None:
|
def register_page() -> None:
|
||||||
"""Register the upload page route."""
|
"""Register the upload page route."""
|
||||||
|
|
||||||
service = DocumentService()
|
|
||||||
|
|
||||||
@ui.page("/upload", title="Upload Document")
|
@ui.page("/upload", title="Upload Document")
|
||||||
def upload_page() -> None:
|
def upload_page(request: Request) -> None:
|
||||||
render_upload_widget(service=service)
|
render_navigation_header(current_path="/upload")
|
||||||
|
session_factory = resolve_session_factory(request.app.state)
|
||||||
|
|
||||||
with ui.row():
|
async def submit_upload(filename: str, file_bytes: bytes):
|
||||||
ui.link("View jobs", "/jobs")
|
async with get_session(session_factory=session_factory) as session:
|
||||||
|
return await create_upload_job(
|
||||||
|
filename=filename,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
notify_worker = resolve_worker_notifier(request.app.state)
|
||||||
|
render_upload_widget(submitter=submit_upload, notifier=notify_worker)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
:root {
|
||||||
|
/* Soft blue-night palette tokens */
|
||||||
|
--ctp-rosewater: #f2dde5;
|
||||||
|
--ctp-flamingo: #edcfd8;
|
||||||
|
--ctp-pink: #dcc7de;
|
||||||
|
--ctp-mauve: #a9bde5;
|
||||||
|
--ctp-red: #d98a9a;
|
||||||
|
--ctp-maroon: #d39aa5;
|
||||||
|
--ctp-peach: #d7af8c;
|
||||||
|
--ctp-yellow: #e2c083;
|
||||||
|
--ctp-green: #86c8ad;
|
||||||
|
--ctp-teal: #77bfbe;
|
||||||
|
--ctp-sky: #7ebdda;
|
||||||
|
--ctp-sapphire: #74aed0;
|
||||||
|
--ctp-blue: #92b5f5;
|
||||||
|
--ctp-lavender: #6f97e8;
|
||||||
|
--ctp-text: #d8e2f5;
|
||||||
|
--ctp-subtext1: #bfcae0;
|
||||||
|
--ctp-subtext0: #a9b6cf;
|
||||||
|
--ctp-overlay2: #95a3bf;
|
||||||
|
--ctp-overlay1: #7c8ca9;
|
||||||
|
--ctp-overlay0: #657490;
|
||||||
|
--ctp-surface2: #4d5f7c;
|
||||||
|
--ctp-surface1: #394a65;
|
||||||
|
--ctp-surface0: #2a3954;
|
||||||
|
--ctp-base: #1f2b42;
|
||||||
|
--ctp-mantle: #1a2538;
|
||||||
|
--ctp-crust: #141e30;
|
||||||
|
|
||||||
|
}
|
||||||
+92
-150
@@ -8,34 +8,93 @@ from collections.abc import AsyncGenerator
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import UTC
|
from typing import Protocol
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from sqlmodel import select
|
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.config import get_settings
|
|
||||||
from transcription.db import get_session
|
from transcription.db import get_session
|
||||||
from transcription.errors import AppError
|
from transcription.errors import AppError
|
||||||
from transcription.errors import ErrorCategory
|
|
||||||
from transcription.errors import classify_unexpected_error
|
from transcription.errors import classify_unexpected_error
|
||||||
from transcription.errors import format_error_detail
|
|
||||||
from transcription.models import Job
|
|
||||||
from transcription.models import JobStatus
|
|
||||||
from transcription.models import Transcript
|
|
||||||
from transcription.services.transcription import TranscriptionError
|
|
||||||
from transcription.services.transcription import transcribe_document_image
|
|
||||||
|
|
||||||
from .services import ServiceBundle
|
from .services import ServiceBundle
|
||||||
|
from .services.documents import DocumentService
|
||||||
from .services.jobs import JobService
|
from .services.jobs import JobService
|
||||||
|
from .services.transcription import TranscriptionService
|
||||||
|
from .services.workflows import advance_job
|
||||||
|
from .services.workflows import process_next_queued_job as process_next_queued_job_workflow
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerNotifier(Protocol):
|
||||||
|
"""Abstraction for signaling the worker loop about new work."""
|
||||||
|
|
||||||
|
def notify(self) -> None:
|
||||||
|
"""Signal the worker loop that work may be available."""
|
||||||
|
|
||||||
|
|
||||||
|
class EventWorkerNotifier:
|
||||||
|
"""Worker notifier backed by an asyncio.Event."""
|
||||||
|
|
||||||
|
def __init__(self, wake_event: asyncio.Event):
|
||||||
|
self._wake_event = wake_event
|
||||||
|
|
||||||
|
def notify(self) -> None:
|
||||||
|
self._wake_event.set()
|
||||||
|
|
||||||
|
|
||||||
|
class NoopWorkerNotifier:
|
||||||
|
"""Fallback notifier used when worker signaling is unavailable."""
|
||||||
|
|
||||||
|
def notify(self) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_worker_notifier(state: object) -> WorkerNotifier:
|
||||||
|
"""Resolve notifier from app-like state objects with no-op fallback."""
|
||||||
|
notifier = getattr(state, "worker_notifier", None)
|
||||||
|
if isinstance(notifier, NoopWorkerNotifier):
|
||||||
|
return notifier
|
||||||
|
if notifier is None:
|
||||||
|
return NoopWorkerNotifier()
|
||||||
|
return notifier
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def worker_consumer_lifespan(
|
||||||
|
*,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||||
|
poll_interval_seconds: float = 1.0,
|
||||||
|
) -> AsyncGenerator[tuple[asyncio.Event, WorkerNotifier]]:
|
||||||
|
"""Start and stop the worker consumer loop for app lifespan."""
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
wake_event = asyncio.Event()
|
||||||
|
worker_notifier: WorkerNotifier = EventWorkerNotifier(wake_event)
|
||||||
|
worker_task = asyncio.create_task(
|
||||||
|
run_worker_loop(
|
||||||
|
session_factory=session_factory,
|
||||||
|
stop_event=stop_event,
|
||||||
|
wake_event=wake_event,
|
||||||
|
poll_interval_seconds=poll_interval_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
worker_notifier.notify()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield stop_event, worker_notifier
|
||||||
|
finally:
|
||||||
|
stop_event.set()
|
||||||
|
worker_notifier.notify()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(worker_task, timeout=2.0)
|
||||||
|
except TimeoutError:
|
||||||
|
worker_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await worker_task
|
||||||
|
|
||||||
|
|
||||||
async def queue_consumer_loop(queue: asyncio.Queue[UUID], stop_event: asyncio.Event):
|
async def queue_consumer_loop(queue: asyncio.Queue[UUID], stop_event: asyncio.Event):
|
||||||
"""Main worker loop that consumes jobs from the queue and processes them.
|
"""Main worker loop that consumes jobs from the queue and processes them.
|
||||||
|
|
||||||
@@ -46,16 +105,16 @@ async def queue_consumer_loop(queue: asyncio.Queue[UUID], stop_event: asyncio.Ev
|
|||||||
with handle_worker_exceptions():
|
with handle_worker_exceptions():
|
||||||
async with _get_queue_item(queue) as job_id:
|
async with _get_queue_item(queue) as job_id:
|
||||||
job = await service.read_job(job_id)
|
job = await service.read_job(job_id)
|
||||||
asyncio.create_task(process_job(job=job, services=ServiceBundle()))
|
asyncio.create_task(advance_job(job=job, services=ServiceBundle()))
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def handle_worker_exceptions():
|
def handle_worker_exceptions(operation: str = "worker.loop"):
|
||||||
"""Context manager to log and suppress exceptions in the worker loop."""
|
"""Context manager to log and suppress exceptions in the worker loop."""
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.loop")
|
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation=operation)
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Worker loop exception error_id=%s category=%s",
|
"Worker loop exception error_id=%s category=%s",
|
||||||
error.error_id,
|
error.error_id,
|
||||||
@@ -70,30 +129,16 @@ async def _get_queue_item(queue: asyncio.Queue[UUID]) -> AsyncGenerator[UUID]:
|
|||||||
queue.task_done()
|
queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
async def process_job(job: Job, services: ServiceBundle) -> None:
|
|
||||||
"""Process a single job using the service bundle."""
|
|
||||||
match job.status:
|
|
||||||
case JobStatus.QUEUED:
|
|
||||||
job.status = JobStatus.PROCESSING
|
|
||||||
try:
|
|
||||||
await services.transcriptions.transcribe_image(job.document.file_path)
|
|
||||||
job.status = JobStatus.TRANSCRIBED
|
|
||||||
except TranscriptionError as exc:
|
|
||||||
job.status = JobStatus.FAILED
|
|
||||||
job.error_message = str(exc)
|
|
||||||
await services.jobs.update_job(job)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_worker_loop(
|
async def run_worker_loop(
|
||||||
*,
|
*,
|
||||||
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
session_factory: async_sessionmaker[AsyncSession] | None = None,
|
||||||
stop_event: asyncio.Event | None = None,
|
stop_event: asyncio.Event | None = None,
|
||||||
wake_queue: asyncio.Queue[None] | None = None,
|
wake_event: asyncio.Event | None = None,
|
||||||
poll_interval_seconds: float = 1.0,
|
poll_interval_seconds: float = 1.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run worker loop until stop_event is set.
|
"""Run worker loop until stop_event is set.
|
||||||
|
|
||||||
If wake_queue is provided, queue activity wakes the loop immediately while
|
If wake_event is provided, signal activity wakes the loop immediately while
|
||||||
timeout-based wakeups preserve current polling behavior.
|
timeout-based wakeups preserve current polling behavior.
|
||||||
"""
|
"""
|
||||||
while True:
|
while True:
|
||||||
@@ -101,15 +146,16 @@ async def run_worker_loop(
|
|||||||
logger.info("Worker stop event received")
|
logger.info("Worker stop event received")
|
||||||
return
|
return
|
||||||
|
|
||||||
if wake_queue is not None:
|
if wake_event is not None:
|
||||||
with suppress(TimeoutError):
|
with suppress(TimeoutError):
|
||||||
await asyncio.wait_for(wake_queue.get(), timeout=poll_interval_seconds)
|
await asyncio.wait_for(wake_event.wait(), timeout=poll_interval_seconds)
|
||||||
|
wake_event.clear()
|
||||||
|
|
||||||
processed_any = False
|
processed_any = False
|
||||||
while await process_next_queued_job(session_factory=session_factory):
|
while await process_next_queued_job(session_factory=session_factory):
|
||||||
processed_any = True
|
processed_any = True
|
||||||
|
|
||||||
if wake_queue is None and not processed_any:
|
if wake_event is None and not processed_any:
|
||||||
await asyncio.sleep(poll_interval_seconds)
|
await asyncio.sleep(poll_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
@@ -122,121 +168,17 @@ async def process_next_queued_job(
|
|||||||
|
|
||||||
Returns True when a job was processed, False when no queued job exists.
|
Returns True when a job was processed, False when no queued job exists.
|
||||||
"""
|
"""
|
||||||
|
if session_factory is None:
|
||||||
|
services = ServiceBundle()
|
||||||
|
else:
|
||||||
|
services = ServiceBundle(
|
||||||
|
documents=DocumentService(session_factory=session_factory),
|
||||||
|
jobs=JobService(session_factory=session_factory),
|
||||||
|
transcriptions=TranscriptionService(session_factory=session_factory),
|
||||||
|
)
|
||||||
|
|
||||||
if session is None:
|
if session is None:
|
||||||
async with get_session(session_factory=session_factory) as local_session:
|
async with get_session(session_factory=session_factory) as local_session:
|
||||||
return await _process_next_queued_job(session=local_session)
|
return await process_next_queued_job_workflow(services=services, session=local_session)
|
||||||
return await _process_next_queued_job(session=session)
|
|
||||||
|
|
||||||
|
return await process_next_queued_job_workflow(services=services, session=session)
|
||||||
async def _process_next_queued_job(*, session: AsyncSession) -> bool:
|
|
||||||
query = (
|
|
||||||
select(Job)
|
|
||||||
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
|
|
||||||
.where(Job.status == JobStatus.QUEUED)
|
|
||||||
.order_by(Job.created_at) # pyright: ignore[reportArgumentType]
|
|
||||||
)
|
|
||||||
job = (await session.exec(query)).first()
|
|
||||||
|
|
||||||
if job is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.info("Picked queued job operation=worker.pick job_id=%s", job.id)
|
|
||||||
job.status = JobStatus.PROCESSING
|
|
||||||
job.updated_at = datetime.now(UTC)
|
|
||||||
session.add(job)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(job)
|
|
||||||
|
|
||||||
document = job.document
|
|
||||||
if document is None:
|
|
||||||
error = AppError(
|
|
||||||
"Document not found",
|
|
||||||
category=ErrorCategory.NOT_FOUND,
|
|
||||||
suggestion="Re-upload the source document and retry processing.",
|
|
||||||
)
|
|
||||||
await _finalize_failed_job(session=session, job=job, error=error)
|
|
||||||
logger.error(
|
|
||||||
"Job failed operation=worker.process_job job_id=%s error_id=%s category=%s",
|
|
||||||
job.id,
|
|
||||||
error.error_id,
|
|
||||||
error.category.value,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Provider SDK calls are synchronous and should not block the event loop.
|
|
||||||
result = await asyncio.to_thread(transcribe_document_image, document.file_path)
|
|
||||||
await _upsert_transcript(session=session, job_id=job.id, text=result.text, error_detail=None)
|
|
||||||
job.status = JobStatus.TRANSCRIBED
|
|
||||||
job.updated_at = datetime.now(UTC)
|
|
||||||
session.add(job)
|
|
||||||
await session.commit()
|
|
||||||
logger.info(
|
|
||||||
"Job transcribed operation=worker.process_job job_id=%s document_id=%s provider=%s",
|
|
||||||
job.id,
|
|
||||||
document.id,
|
|
||||||
result.provider,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
error = exc if isinstance(exc, AppError) else classify_unexpected_error(exc, operation="worker.process_job")
|
|
||||||
settings = get_settings()
|
|
||||||
if _should_retry(job=job, error=error, settings=settings):
|
|
||||||
await _requeue_for_retry(session=session, job=job, error=error, settings=settings)
|
|
||||||
logger.warning(
|
|
||||||
"Job retried operation=worker.process_job "
|
|
||||||
"job_id=%s document_id=%s retry_count=%s error_id=%s category=%s",
|
|
||||||
job.id,
|
|
||||||
document.id,
|
|
||||||
job.retry_count,
|
|
||||||
error.error_id,
|
|
||||||
error.category.value,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await _finalize_failed_job(session=session, job=job, error=error)
|
|
||||||
logger.exception(
|
|
||||||
"Job failed operation=worker.process_job job_id=%s document_id=%s error_id=%s category=%s",
|
|
||||||
job.id,
|
|
||||||
document.id,
|
|
||||||
error.error_id,
|
|
||||||
error.category.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def _upsert_transcript(
|
|
||||||
*, session: AsyncSession, job_id, text: str | None, error_detail: str | None
|
|
||||||
) -> Transcript:
|
|
||||||
transcript = (await session.exec(select(Transcript).where(Transcript.job_id == job_id))).first()
|
|
||||||
if transcript is None:
|
|
||||||
transcript = Transcript(job_id=job_id)
|
|
||||||
|
|
||||||
transcript.text = text
|
|
||||||
transcript.error_detail = error_detail
|
|
||||||
session.add(transcript)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(transcript)
|
|
||||||
return transcript
|
|
||||||
|
|
||||||
|
|
||||||
def _should_retry(*, job: Job, error: AppError, settings: Settings) -> bool:
|
|
||||||
return error.retriable and job.retry_count < settings.worker_max_retries
|
|
||||||
|
|
||||||
|
|
||||||
async def _requeue_for_retry(*, session: AsyncSession, job: Job, error: AppError, settings: Settings) -> None:
|
|
||||||
await _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
|
|
||||||
job.retry_count += 1
|
|
||||||
job.status = JobStatus.QUEUED
|
|
||||||
job.updated_at = datetime.now(UTC)
|
|
||||||
session.add(job)
|
|
||||||
await session.commit()
|
|
||||||
if settings.worker_retry_backoff_seconds > 0:
|
|
||||||
await asyncio.sleep(settings.worker_retry_backoff_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
async def _finalize_failed_job(*, session: AsyncSession, job: Job, error: AppError) -> None:
|
|
||||||
await _upsert_transcript(session=session, job_id=job.id, text=None, error_detail=format_error_detail(error))
|
|
||||||
job.status = JobStatus.FAILED
|
|
||||||
job.updated_at = datetime.now(UTC)
|
|
||||||
session.add(job)
|
|
||||||
await session.commit()
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ from transcription.db.operations import create_all
|
|||||||
from transcription.db.runtime import dispose_database_runtime
|
from transcription.db.runtime import dispose_database_runtime
|
||||||
from transcription.db.runtime import get_engine
|
from transcription.db.runtime import get_engine
|
||||||
from transcription.db.runtime import get_session
|
from transcription.db.runtime import get_session
|
||||||
|
from transcription.db.runtime import get_session_factory
|
||||||
|
from transcription.services.documents import DocumentService
|
||||||
|
from transcription.services.jobs import JobService
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -47,3 +50,22 @@ async def async_session(default_settings: Settings):
|
|||||||
yield async_session
|
yield async_session
|
||||||
|
|
||||||
await dispose_database_runtime()
|
await dispose_database_runtime()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_session_factory(default_settings: Settings):
|
||||||
|
"""Provide a base fixture for tests that require database access."""
|
||||||
|
session_factory = get_session_factory(settings=default_settings)
|
||||||
|
return session_factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def job_service(default_session_factory) -> JobService:
|
||||||
|
"""Provide a JobService instance for testing."""
|
||||||
|
return JobService(session_factory=default_session_factory)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def document_service(default_session_factory) -> DocumentService:
|
||||||
|
"""Provide a DocumentService instance for testing."""
|
||||||
|
return DocumentService(session_factory=default_session_factory)
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Tests for transcription.services.transcription."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.providers.base import ProviderError, TranscriptionResult
|
|
||||||
from transcription.services.transcription import (
|
|
||||||
PromptLoadError,
|
|
||||||
TranscriptionError,
|
|
||||||
load_image_payload,
|
|
||||||
load_prompt_text,
|
|
||||||
transcribe_document_image,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeProvider:
|
|
||||||
def __init__(self, *, result: TranscriptionResult | None = None, error: Exception | None = None):
|
|
||||||
self._result = result or TranscriptionResult(
|
|
||||||
text="Transcript output",
|
|
||||||
provider="openrouter",
|
|
||||||
model="test-model",
|
|
||||||
)
|
|
||||||
self._error = error
|
|
||||||
self.calls: list[dict[str, object]] = []
|
|
||||||
|
|
||||||
def transcribe(self, *, prompt_text: str, image_bytes: bytes, mime_type: str) -> TranscriptionResult:
|
|
||||||
self.calls.append(
|
|
||||||
{
|
|
||||||
"prompt_text": prompt_text,
|
|
||||||
"image_bytes": image_bytes,
|
|
||||||
"mime_type": mime_type,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if self._error:
|
|
||||||
raise self._error
|
|
||||||
return self._result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
class TestPromptLoading:
|
|
||||||
"""Verify prompt artifact loading and validation."""
|
|
||||||
|
|
||||||
def test_loads_prompt_text_from_prompt_dir(self, tmp_path: Path):
|
|
||||||
"""Prompt loader returns canonical prompt text from configured prompt directory."""
|
|
||||||
prompt_dir = tmp_path / "prompts"
|
|
||||||
prompt_dir.mkdir()
|
|
||||||
prompt_file = prompt_dir / "transcribe_document.md"
|
|
||||||
prompt_file.write_text("Prompt body", encoding="utf-8")
|
|
||||||
|
|
||||||
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
|
||||||
text = load_prompt_text(settings=settings)
|
|
||||||
|
|
||||||
assert text == "Prompt body"
|
|
||||||
|
|
||||||
def test_missing_prompt_raises_error(self, tmp_path: Path):
|
|
||||||
"""Prompt loader raises PromptLoadError when the file is missing."""
|
|
||||||
prompt_dir = tmp_path / "prompts"
|
|
||||||
prompt_dir.mkdir()
|
|
||||||
|
|
||||||
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
|
||||||
with pytest.raises(PromptLoadError) as exc_info:
|
|
||||||
load_prompt_text(settings=settings)
|
|
||||||
|
|
||||||
assert exc_info.value.category.value == "infrastructure_persistent_error"
|
|
||||||
assert "verify prompt_dir" in exc_info.value.suggestion.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
class TestImageLoading:
|
|
||||||
"""Verify local image payload loading and mime detection."""
|
|
||||||
|
|
||||||
def test_load_image_payload_reads_bytes_and_mime_type(self, tmp_path: Path):
|
|
||||||
"""Image loader returns file bytes and a detected MIME type for supported files."""
|
|
||||||
image_path = tmp_path / "sample.png"
|
|
||||||
image_bytes = b"\x89PNG\r\n\x1a\n"
|
|
||||||
image_path.write_bytes(image_bytes)
|
|
||||||
|
|
||||||
loaded_bytes, mime_type = load_image_payload(image_path)
|
|
||||||
|
|
||||||
assert loaded_bytes == image_bytes
|
|
||||||
assert mime_type == "image/png"
|
|
||||||
|
|
||||||
def test_missing_image_raises_error(self, tmp_path: Path):
|
|
||||||
"""Image loader raises TranscriptionError when image file does not exist."""
|
|
||||||
missing = tmp_path / "missing.png"
|
|
||||||
|
|
||||||
with pytest.raises(TranscriptionError) as exc_info:
|
|
||||||
load_image_payload(missing)
|
|
||||||
|
|
||||||
assert exc_info.value.category.value == "not_found_error"
|
|
||||||
assert "verify" in exc_info.value.suggestion.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
class TestTranscriptionService:
|
|
||||||
"""Verify service orchestration across prompt, image, and provider calls."""
|
|
||||||
|
|
||||||
def test_transcribe_document_image_calls_provider_once(self, tmp_path: Path):
|
|
||||||
"""Service loads prompt and image, then invokes provider exactly once."""
|
|
||||||
prompt_dir = tmp_path / "prompts"
|
|
||||||
prompt_dir.mkdir()
|
|
||||||
(prompt_dir / "transcribe_document.md").write_text("Prompt body", encoding="utf-8")
|
|
||||||
|
|
||||||
image_path = tmp_path / "document.jpg"
|
|
||||||
image_path.write_bytes(b"jpeg-bytes")
|
|
||||||
|
|
||||||
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
|
||||||
provider = _FakeProvider()
|
|
||||||
|
|
||||||
result = transcribe_document_image(image_path, settings=settings, provider=provider)
|
|
||||||
|
|
||||||
assert result.text == "Transcript output"
|
|
||||||
assert len(provider.calls) == 1
|
|
||||||
assert provider.calls[0]["prompt_text"] == "Prompt body"
|
|
||||||
assert provider.calls[0]["image_bytes"] == b"jpeg-bytes"
|
|
||||||
assert provider.calls[0]["mime_type"] == "image/jpeg"
|
|
||||||
|
|
||||||
def test_provider_error_is_wrapped(self, tmp_path: Path):
|
|
||||||
"""Service wraps provider failures in TranscriptionError."""
|
|
||||||
prompt_dir = tmp_path / "prompts"
|
|
||||||
prompt_dir.mkdir()
|
|
||||||
(prompt_dir / "transcribe_document.md").write_text("Prompt body", encoding="utf-8")
|
|
||||||
|
|
||||||
image_path = tmp_path / "document.png"
|
|
||||||
image_path.write_bytes(b"png-bytes")
|
|
||||||
|
|
||||||
settings = Settings(openrouter_api_key="test-key", prompt_dir=prompt_dir)
|
|
||||||
provider = _FakeProvider(error=ProviderError("upstream failure"))
|
|
||||||
|
|
||||||
with pytest.raises(TranscriptionError) as exc_info:
|
|
||||||
transcribe_document_image(image_path, settings=settings, provider=provider)
|
|
||||||
|
|
||||||
assert exc_info.value.category.value == "external_provider_error"
|
|
||||||
assert exc_info.value.retriable is True
|
|
||||||
assert "retry" in exc_info.value.suggestion.lower()
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
"""Tests for transcription.services.upload."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.models import Document, Job, JobStatus
|
|
||||||
from transcription.services.upload import UploadError, create_upload_job
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
class TestUploadValidation:
|
|
||||||
"""Verify upload validation behavior."""
|
|
||||||
|
|
||||||
def test_rejects_empty_bytes(self, session, tmp_path: Path):
|
|
||||||
"""create_upload_job rejects an empty upload payload."""
|
|
||||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
|
||||||
with pytest.raises(UploadError) as exc_info:
|
|
||||||
create_upload_job(
|
|
||||||
filename="letter.jpg",
|
|
||||||
file_bytes=b"",
|
|
||||||
session=session,
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert exc_info.value.category.value == "validation_error"
|
|
||||||
assert "non-empty" in exc_info.value.suggestion.lower()
|
|
||||||
|
|
||||||
def test_rejects_unsupported_extension(self, session, tmp_path: Path):
|
|
||||||
"""create_upload_job rejects unsupported filename extensions."""
|
|
||||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
|
||||||
with pytest.raises(UploadError) as exc_info:
|
|
||||||
create_upload_job(
|
|
||||||
filename="notes.txt",
|
|
||||||
file_bytes=b"content",
|
|
||||||
session=session,
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert exc_info.value.category.value == "user_input_error"
|
|
||||||
assert "jpg" in exc_info.value.suggestion.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestUploadPersistence:
|
|
||||||
"""Verify upload file and record persistence behavior."""
|
|
||||||
|
|
||||||
def test_writes_file_and_creates_records(self, session, tmp_path: Path):
|
|
||||||
"""create_upload_job writes file and creates document/job records."""
|
|
||||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
|
||||||
|
|
||||||
result = create_upload_job(
|
|
||||||
filename="letter.jpg",
|
|
||||||
file_bytes=b"image-bytes",
|
|
||||||
session=session,
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.stored_path.exists()
|
|
||||||
assert result.stored_path.read_bytes() == b"image-bytes"
|
|
||||||
|
|
||||||
document = session.get(Document, result.document_id)
|
|
||||||
job = session.get(Job, result.job_id)
|
|
||||||
assert document is not None
|
|
||||||
assert job is not None
|
|
||||||
assert document.filename == "letter.jpg"
|
|
||||||
assert document.file_path == str(result.stored_path)
|
|
||||||
|
|
||||||
def test_uses_unique_stored_filename(self, session, tmp_path: Path):
|
|
||||||
"""create_upload_job stores uploads with unique filenames."""
|
|
||||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
|
||||||
|
|
||||||
first = create_upload_job(
|
|
||||||
filename="duplicate.jpg",
|
|
||||||
file_bytes=b"first",
|
|
||||||
session=session,
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
second = create_upload_job(
|
|
||||||
filename="duplicate.jpg",
|
|
||||||
file_bytes=b"second",
|
|
||||||
session=session,
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert first.stored_path != second.stored_path
|
|
||||||
assert first.stored_path.exists()
|
|
||||||
assert second.stored_path.exists()
|
|
||||||
|
|
||||||
def test_sets_job_status_queued(self, session, tmp_path: Path):
|
|
||||||
"""create_upload_job persists a job with queued status."""
|
|
||||||
settings = Settings(openrouter_api_key="test-key", upload_dir=tmp_path)
|
|
||||||
|
|
||||||
result = create_upload_job(
|
|
||||||
filename="queued.pdf",
|
|
||||||
file_bytes=b"%PDF-1.4",
|
|
||||||
session=session,
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
job = session.get(Job, result.job_id)
|
|
||||||
assert job is not None
|
|
||||||
assert job.status == JobStatus.QUEUED
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
"""Tests for transcription.worker."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from threading import Event
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from transcription.config import Settings
|
|
||||||
from transcription.errors import AppError, ErrorCategory
|
|
||||||
from transcription.models import Document, Job, JobStatus, Transcript
|
|
||||||
from transcription.providers.base import TranscriptionResult
|
|
||||||
from transcription.worker import process_next_queued_job, run_worker_loop
|
|
||||||
|
|
||||||
|
|
||||||
def _create_queued_job(session, *, filename: str = "doc.jpg", file_path: str = "uploads/doc.jpg") -> Job:
|
|
||||||
document = Document(filename=filename, file_path=file_path)
|
|
||||||
session.add(document)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(document)
|
|
||||||
|
|
||||||
job = Job(document_id=document.id, status=JobStatus.QUEUED)
|
|
||||||
session.add(job)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(job)
|
|
||||||
return job
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestWorkerQueueBehavior:
|
|
||||||
"""Verify worker behavior when selecting queued jobs."""
|
|
||||||
|
|
||||||
def test_returns_false_when_queue_empty(self, session):
|
|
||||||
"""process_next_queued_job returns False when there are no queued jobs."""
|
|
||||||
processed = process_next_queued_job(session=session)
|
|
||||||
assert processed is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestWorkerSuccessPath:
|
|
||||||
"""Verify worker success-path lifecycle transitions and transcript persistence."""
|
|
||||||
|
|
||||||
def test_transitions_processing_to_transcribed(self, session, monkeypatch, tmp_path: Path):
|
|
||||||
"""process_next_queued_job transitions queued jobs to transcribed on success."""
|
|
||||||
job = _create_queued_job(session)
|
|
||||||
|
|
||||||
def _fake_transcribe(_path):
|
|
||||||
return TranscriptionResult(text="ok", provider="openrouter", model="test-model")
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"transcription.worker.get_settings",
|
|
||||||
lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path),
|
|
||||||
)
|
|
||||||
|
|
||||||
processed = process_next_queued_job(session=session)
|
|
||||||
session.refresh(job)
|
|
||||||
|
|
||||||
assert processed is True
|
|
||||||
assert job.status == JobStatus.TRANSCRIBED
|
|
||||||
|
|
||||||
def test_persists_transcript_text_on_success(self, session, monkeypatch, tmp_path: Path):
|
|
||||||
"""process_next_queued_job stores transcript text for successful jobs."""
|
|
||||||
job = _create_queued_job(session)
|
|
||||||
|
|
||||||
def _fake_transcribe(_path):
|
|
||||||
return TranscriptionResult(text="Transcript body", provider="openrouter", model="test-model")
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"transcription.worker.get_settings",
|
|
||||||
lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path),
|
|
||||||
)
|
|
||||||
|
|
||||||
process_next_queued_job(session=session)
|
|
||||||
|
|
||||||
transcript = session.exec(
|
|
||||||
select(Transcript).where(Transcript.job_id == job.id)
|
|
||||||
).first()
|
|
||||||
assert transcript is not None
|
|
||||||
assert transcript.text == "Transcript body"
|
|
||||||
assert transcript.error_detail is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestWorkerFailurePath:
|
|
||||||
"""Verify worker failure-path lifecycle transitions and error persistence."""
|
|
||||||
|
|
||||||
def test_sets_failed_and_error_detail_on_failure(self, session, monkeypatch, tmp_path: Path):
|
|
||||||
"""process_next_queued_job marks failed and stores error detail on exception."""
|
|
||||||
job = _create_queued_job(session)
|
|
||||||
|
|
||||||
def _fake_transcribe(_path):
|
|
||||||
raise RuntimeError("provider failure")
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"transcription.worker.get_settings",
|
|
||||||
lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path),
|
|
||||||
)
|
|
||||||
|
|
||||||
processed = process_next_queued_job(session=session)
|
|
||||||
session.refresh(job)
|
|
||||||
transcript = session.exec(
|
|
||||||
select(Transcript).where(Transcript.job_id == job.id)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
assert processed is True
|
|
||||||
assert job.status == JobStatus.FAILED
|
|
||||||
assert transcript is not None
|
|
||||||
assert transcript.text is None
|
|
||||||
assert "provider failure" in transcript.error_detail
|
|
||||||
assert "[internal_unexpected_error]" in transcript.error_detail
|
|
||||||
assert "error_id=" in transcript.error_detail
|
|
||||||
assert "suggestion=" in transcript.error_detail
|
|
||||||
|
|
||||||
def test_updates_existing_transcript_if_present(self, session, monkeypatch, tmp_path: Path):
|
|
||||||
"""process_next_queued_job updates existing transcript instead of duplicating."""
|
|
||||||
job = _create_queued_job(session)
|
|
||||||
existing = Transcript(job_id=job.id, text="old", error_detail=None)
|
|
||||||
session.add(existing)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(existing)
|
|
||||||
|
|
||||||
def _fake_transcribe(_path):
|
|
||||||
raise RuntimeError("provider failure")
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"transcription.worker.get_settings",
|
|
||||||
lambda: Settings(openrouter_api_key="test-key", prompt_dir=tmp_path),
|
|
||||||
)
|
|
||||||
|
|
||||||
process_next_queued_job(session=session)
|
|
||||||
|
|
||||||
transcripts = session.exec(
|
|
||||||
select(Transcript).where(Transcript.job_id == job.id)
|
|
||||||
).all()
|
|
||||||
assert len(transcripts) == 1
|
|
||||||
assert transcripts[0].id == existing.id
|
|
||||||
assert transcripts[0].text is None
|
|
||||||
assert "provider failure" in transcripts[0].error_detail
|
|
||||||
assert "[internal_unexpected_error]" in transcripts[0].error_detail
|
|
||||||
assert "error_id=" in transcripts[0].error_detail
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestWorkerRetryBehavior:
|
|
||||||
"""Verify worker retry and terminal failure policies."""
|
|
||||||
|
|
||||||
def test_retriable_failure_requeues_until_limit(self, session, monkeypatch, tmp_path: Path):
|
|
||||||
"""Retriable failures requeue jobs while retry budget remains."""
|
|
||||||
job = _create_queued_job(session)
|
|
||||||
|
|
||||||
def _fake_transcribe(_path):
|
|
||||||
raise AppError(
|
|
||||||
"temporary upstream outage",
|
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
|
||||||
suggestion="Retry from jobs page.",
|
|
||||||
retriable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"transcription.worker.get_settings",
|
|
||||||
lambda: Settings(
|
|
||||||
openrouter_api_key="test-key",
|
|
||||||
prompt_dir=tmp_path,
|
|
||||||
worker_max_retries=1,
|
|
||||||
worker_retry_backoff_seconds=0.0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
processed = process_next_queued_job(session=session)
|
|
||||||
session.refresh(job)
|
|
||||||
|
|
||||||
assert processed is True
|
|
||||||
assert job.status == JobStatus.QUEUED
|
|
||||||
assert job.retry_count == 1
|
|
||||||
|
|
||||||
def test_retriable_failure_exhaustion_sets_failed(self, session, monkeypatch, tmp_path: Path):
|
|
||||||
"""Retriable failures transition to failed when retry budget is exhausted."""
|
|
||||||
job = _create_queued_job(session)
|
|
||||||
job.retry_count = 1
|
|
||||||
session.add(job)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
def _fake_transcribe(_path):
|
|
||||||
raise AppError(
|
|
||||||
"temporary upstream outage",
|
|
||||||
category=ErrorCategory.EXTERNAL_PROVIDER,
|
|
||||||
suggestion="Retry from jobs page.",
|
|
||||||
retriable=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.worker.transcribe_document_image", _fake_transcribe)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"transcription.worker.get_settings",
|
|
||||||
lambda: Settings(
|
|
||||||
openrouter_api_key="test-key",
|
|
||||||
prompt_dir=tmp_path,
|
|
||||||
worker_max_retries=1,
|
|
||||||
worker_retry_backoff_seconds=0.0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
process_next_queued_job(session=session)
|
|
||||||
session.refresh(job)
|
|
||||||
|
|
||||||
assert job.status == JobStatus.FAILED
|
|
||||||
assert job.retry_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
|
||||||
class TestWorkerLoopControl:
|
|
||||||
"""Verify worker loop start/stop behavior."""
|
|
||||||
|
|
||||||
def test_stops_when_stop_event_is_set(self, monkeypatch):
|
|
||||||
"""run_worker_loop exits when a stop event is set."""
|
|
||||||
stop_event = Event()
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
called = {"value": False}
|
|
||||||
|
|
||||||
def _fake_process_next_queued_job(**_kwargs):
|
|
||||||
called["value"] = True
|
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.worker.process_next_queued_job", _fake_process_next_queued_job)
|
|
||||||
|
|
||||||
run_worker_loop(stop_event=stop_event, poll_interval_seconds=0.01)
|
|
||||||
assert called["value"] is False
|
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Shared fixtures for UI integration tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import delete
|
||||||
|
|
||||||
|
from transcription.app import create_app
|
||||||
|
from transcription.config import Settings
|
||||||
|
from transcription.config import _settings
|
||||||
|
from transcription.db import create_all
|
||||||
|
from transcription.db import get_session
|
||||||
|
from transcription.db import initialize_database_runtime
|
||||||
|
from transcription.models import Document
|
||||||
|
from transcription.models import Job
|
||||||
|
from transcription.models import JobStatus
|
||||||
|
from transcription.models import Transcript
|
||||||
|
|
||||||
|
TranscriptSeed = tuple[int, str | None, str | None]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app_client(tmp_path_factory: pytest.TempPathFactory) -> tuple[FastAPI, TestClient]:
|
||||||
|
"""Provide a real application and test client backed by in-memory SQLite."""
|
||||||
|
tmp_path = tmp_path_factory.mktemp("ui")
|
||||||
|
settings = Settings(
|
||||||
|
openrouter_api_key="test-key",
|
||||||
|
database_url="sqlite:///:memory:",
|
||||||
|
environment="test",
|
||||||
|
bootstrap_schema_on_startup=True,
|
||||||
|
upload_dir=tmp_path / "uploads",
|
||||||
|
prompt_dir=tmp_path / "prompts",
|
||||||
|
)
|
||||||
|
_settings.set(settings)
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
app.state.runtime = initialize_database_runtime(settings=settings)
|
||||||
|
asyncio.run(create_all(engine=app.state.runtime.engine))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield app, client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_ui_database(app_client: tuple[FastAPI, TestClient]) -> None:
|
||||||
|
"""Reset UI-facing tables before each test for isolation."""
|
||||||
|
app, _ = app_client
|
||||||
|
|
||||||
|
async def _clear() -> None:
|
||||||
|
async with get_session(session_factory=app.state.runtime.session_factory) as session:
|
||||||
|
await session.exec(delete(Transcript))
|
||||||
|
await session.exec(delete(Job))
|
||||||
|
await session.exec(delete(Document))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.run(_clear())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
|
||||||
|
"""Return a helper for inserting a document/job/transcript trio."""
|
||||||
|
app, _ = app_client
|
||||||
|
fixtures_dir = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid"
|
||||||
|
|
||||||
|
def _seed(
|
||||||
|
*,
|
||||||
|
filename: str = "sample.pdf",
|
||||||
|
status: JobStatus = JobStatus.TRANSCRIBED,
|
||||||
|
transcript_text: str | None = "Sample transcript text",
|
||||||
|
error_detail: str | None = None,
|
||||||
|
transcript_revisions: list[TranscriptSeed] | None = None,
|
||||||
|
source_file: Path | None = None,
|
||||||
|
) -> UUID:
|
||||||
|
async def _insert() -> UUID:
|
||||||
|
async with get_session(session_factory=app.state.runtime.session_factory) as session:
|
||||||
|
stored_path = app.state.settings.upload_dir / filename
|
||||||
|
stored_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source_path = source_file or fixtures_dir / "small_png.png"
|
||||||
|
stored_path.write_bytes(source_path.read_bytes())
|
||||||
|
|
||||||
|
document = Document(filename=filename, file_path=str(stored_path))
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
job = Job(document_id=document.id, status=status, retry_count=0)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
revisions = transcript_revisions
|
||||||
|
if revisions is None and (transcript_text is not None or error_detail is not None):
|
||||||
|
revisions = [(0, transcript_text, error_detail)]
|
||||||
|
|
||||||
|
if revisions is not None:
|
||||||
|
for revision, revision_text, revision_error in revisions:
|
||||||
|
session.add(
|
||||||
|
Transcript(
|
||||||
|
job_id=job.id,
|
||||||
|
revision=revision,
|
||||||
|
provider="openrouter",
|
||||||
|
model="google/gemini-2.5-flash",
|
||||||
|
prompt_name="transcribe_document",
|
||||||
|
text=revision_text,
|
||||||
|
error_detail=revision_error,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return job.id
|
||||||
|
|
||||||
|
return asyncio.run(_insert())
|
||||||
|
|
||||||
|
return _seed
|
||||||
+57
-75
@@ -1,95 +1,77 @@
|
|||||||
"""Tests for transcription.ui.jobs_page."""
|
"""Tests for the jobs page route."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from transcription.models import Document, Job, Transcript
|
from transcription.models import JobStatus
|
||||||
from transcription.ui.jobs_page import fetch_job_detail, fetch_jobs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
class TestJobsListBehavior:
|
class TestPageRendering:
|
||||||
"""Verify job list data and rendering helpers."""
|
"""Verify jobs routes render correctly with real app wiring."""
|
||||||
|
|
||||||
def test_fetch_jobs_returns_job_view_rows(self, session, monkeypatch):
|
def test_jobs_page_renders_empty_state(self, app_client):
|
||||||
"""fetch_jobs returns normalized JobView rows for UI consumption."""
|
"""GET /ui/jobs renders the page and empty-state text when no jobs exist."""
|
||||||
document = Document(filename="letter.jpg", file_path="uploads/letter.jpg")
|
_, client = app_client
|
||||||
session.add(document)
|
response = client.get("/ui/jobs")
|
||||||
session.commit()
|
|
||||||
session.refresh(document)
|
|
||||||
|
|
||||||
job = Job(document_id=document.id)
|
assert response.status_code == 200
|
||||||
session.add(job)
|
assert "Transcription Jobs" in response.text
|
||||||
session.commit()
|
assert "No jobs yet." in response.text
|
||||||
|
|
||||||
class _SessionContext:
|
def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
|
||||||
def __enter__(self):
|
"""GET /ui/jobs lists seeded jobs from the in-memory database."""
|
||||||
return session
|
_, client = app_client
|
||||||
|
seed_job(filename="sample.pdf", status=JobStatus.TRANSCRIBED, transcript_text="done")
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb):
|
response = client.get("/ui/jobs")
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
|
assert response.status_code == 200
|
||||||
|
assert "sample.pdf" in response.text
|
||||||
|
assert "transcribed" in response.text
|
||||||
|
|
||||||
rows = fetch_jobs()
|
def test_job_detail_page_renders_seeded_job(self, app_client, seed_job):
|
||||||
|
"""GET /ui/jobs/{job_id} renders detail content for a real seeded job."""
|
||||||
|
_, client = app_client
|
||||||
|
fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "images" / "valid" / "single_page_pdf.pdf"
|
||||||
|
job_id = seed_job(
|
||||||
|
filename="detail.pdf",
|
||||||
|
status=JobStatus.TRANSCRIBED,
|
||||||
|
transcript_revisions=[
|
||||||
|
(0, None, "first attempt failed"),
|
||||||
|
(1, "hello", None),
|
||||||
|
],
|
||||||
|
source_file=fixture_path,
|
||||||
|
)
|
||||||
|
|
||||||
assert len(rows) == 1
|
response = client.get(f"/ui/jobs/{job_id}")
|
||||||
assert rows[0].id == job.id
|
|
||||||
assert rows[0].status == "queued"
|
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Job Detail" in response.text
|
||||||
|
assert "Job overview" in response.text
|
||||||
|
assert "detail.pdf" in response.text
|
||||||
|
assert "Transcripts" in response.text
|
||||||
|
assert "Revision" in response.text
|
||||||
|
assert "first attempt failed" in response.text
|
||||||
|
assert "hello" in response.text
|
||||||
|
assert "Document preview" in response.text
|
||||||
|
assert "/uploads/detail.pdf" in response.text
|
||||||
|
|
||||||
@pytest.mark.integration
|
def test_job_detail_page_rejects_invalid_id(self, app_client):
|
||||||
class TestJobDetailBehavior:
|
"""GET /ui/jobs/{job_id} shows validation feedback for malformed IDs."""
|
||||||
"""Verify job detail retrieval behavior."""
|
_, client = app_client
|
||||||
|
response = client.get("/ui/jobs/not-a-uuid")
|
||||||
|
|
||||||
def test_fetch_job_detail_returns_related_records_when_present(self, session, monkeypatch):
|
assert response.status_code == 200
|
||||||
"""fetch_job_detail returns job, document, and transcript when available."""
|
assert "Invalid job id" in response.text
|
||||||
document = Document(filename="typed.jpg", file_path="uploads/typed.jpg")
|
|
||||||
session.add(document)
|
|
||||||
session.commit()
|
|
||||||
session.refresh(document)
|
|
||||||
|
|
||||||
job = Job(document_id=document.id)
|
def test_job_detail_page_handles_missing_job(self, app_client):
|
||||||
session.add(job)
|
"""GET /ui/jobs/{job_id} shows not-found state for unknown IDs."""
|
||||||
session.commit()
|
_, client = app_client
|
||||||
session.refresh(job)
|
missing_id = uuid4()
|
||||||
|
response = client.get(f"/ui/jobs/{missing_id}")
|
||||||
|
|
||||||
transcript = Transcript(job_id=job.id, text="Transcript text")
|
assert response.status_code == 200
|
||||||
session.add(transcript)
|
assert "Job not found" in response.text
|
||||||
session.commit()
|
|
||||||
|
|
||||||
class _SessionContext:
|
|
||||||
def __enter__(self):
|
|
||||||
return session
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
|
|
||||||
|
|
||||||
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(job.id)
|
|
||||||
|
|
||||||
assert fetched_job is not None
|
|
||||||
assert fetched_document is not None
|
|
||||||
assert fetched_transcript is not None
|
|
||||||
assert fetched_job.id == job.id
|
|
||||||
assert fetched_document.id == document.id
|
|
||||||
assert fetched_transcript.job_id == job.id
|
|
||||||
|
|
||||||
def test_fetch_job_detail_returns_nones_for_missing_job(self, session, monkeypatch):
|
|
||||||
"""fetch_job_detail returns triple None when job does not exist."""
|
|
||||||
class _SessionContext:
|
|
||||||
def __enter__(self):
|
|
||||||
return session
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr("transcription.ui.jobs_page.get_session", lambda: _SessionContext())
|
|
||||||
|
|
||||||
fetched_job, fetched_document, fetched_transcript = fetch_job_detail(uuid4())
|
|
||||||
|
|
||||||
assert fetched_job is None
|
|
||||||
assert fetched_document is None
|
|
||||||
assert fetched_transcript is None
|
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
"""Tests for UI page registration wiring."""
|
"""Tests for UI page registration wiring."""
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from transcription.ui import register_pages
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
class TestPageRegistration:
|
class TestPageRegistration:
|
||||||
"""Verify page registration and route wiring."""
|
"""Verify page registration and mounted UI routes."""
|
||||||
|
|
||||||
def test_register_pages_adds_expected_routes(self):
|
def test_ui_mount_serves_registered_pages(self, app_client):
|
||||||
"""register_pages wires upload and jobs routes into the app."""
|
"""Mounted UI routes respond successfully when the full app is created."""
|
||||||
app = FastAPI()
|
_, client = app_client
|
||||||
register_pages(app)
|
|
||||||
app.add_api_route("/healthz", lambda: {"status": "ok"}, methods=["GET"])
|
|
||||||
|
|
||||||
client = TestClient(app)
|
upload_response = client.get("/ui/upload")
|
||||||
ui_response = client.get("/ui")
|
jobs_response = client.get("/ui/jobs")
|
||||||
health_response = client.get("/healthz")
|
|
||||||
|
|
||||||
assert ui_response.status_code == 200
|
assert upload_response.status_code == 200
|
||||||
assert health_response.status_code == 200
|
assert jobs_response.status_code == 200
|
||||||
assert health_response.json() == {"status": "ok"}
|
|
||||||
|
|||||||
@@ -1,53 +1,35 @@
|
|||||||
"""Tests for transcription.ui.upload_page."""
|
"""Tests for upload and entry-point routes."""
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from transcription.services.upload import UploadError, UploadJobResult
|
|
||||||
from transcription.ui import upload_page
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestPageRendering:
|
||||||
|
"""Verify upload-related routes return working pages."""
|
||||||
|
|
||||||
@pytest.mark.unit
|
def test_root_redirects_to_ui(self, app_client):
|
||||||
class TestUploadPageBehavior:
|
"""GET / redirects to the UI mount point."""
|
||||||
"""Verify upload page helper and submission behavior."""
|
_, client = app_client
|
||||||
|
response = client.get("/", follow_redirects=False)
|
||||||
|
|
||||||
def test_accepted_upload_types_contains_supported_extensions(self):
|
assert response.status_code == 307
|
||||||
"""accepted_upload_types includes all MVP-supported upload extensions."""
|
assert response.headers["location"] == "/ui"
|
||||||
accepted = upload_page.accepted_upload_types()
|
|
||||||
assert ".jpg" in accepted
|
|
||||||
assert ".jpeg" in accepted
|
|
||||||
assert ".png" in accepted
|
|
||||||
assert ".tif" in accepted
|
|
||||||
assert ".tiff" in accepted
|
|
||||||
assert ".pdf" in accepted
|
|
||||||
|
|
||||||
def test_submit_upload_calls_upload_service(self, monkeypatch):
|
def test_ui_redirects_to_upload(self, app_client):
|
||||||
"""submit_upload delegates file persistence and job creation to upload service."""
|
"""GET /ui redirects to the upload page."""
|
||||||
expected = UploadJobResult(
|
_, client = app_client
|
||||||
document_id=uuid4(),
|
response = client.get("/ui", follow_redirects=False)
|
||||||
job_id=uuid4(),
|
|
||||||
stored_path=Path("uploads/mock.jpg"),
|
|
||||||
original_filename="mock.jpg",
|
|
||||||
)
|
|
||||||
|
|
||||||
def fake_create_upload_job(*, filename: str, file_bytes: bytes):
|
assert response.status_code == 307
|
||||||
assert filename == "mock.jpg"
|
assert response.headers["location"] == "/ui/upload"
|
||||||
assert file_bytes == b"bytes"
|
|
||||||
return expected
|
|
||||||
|
|
||||||
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job)
|
def test_upload_page_renders_expected_controls(self, app_client):
|
||||||
|
"""GET /ui/upload returns the page shell and upload controls."""
|
||||||
|
_, client = app_client
|
||||||
|
response = client.get("/ui/upload")
|
||||||
|
|
||||||
result = upload_page.submit_upload(filename="mock.jpg", file_bytes=b"bytes")
|
assert response.status_code == 200
|
||||||
assert result == expected
|
assert "Upload Document" in response.text
|
||||||
|
assert "Select document file" in response.text
|
||||||
def test_submit_upload_surfaces_upload_error(self, monkeypatch):
|
assert "Upload" in response.text
|
||||||
"""submit_upload raises UploadError for invalid upload payloads."""
|
assert "Jobs" in response.text
|
||||||
def fake_create_upload_job(*, filename: str, file_bytes: bytes):
|
|
||||||
raise UploadError("invalid payload")
|
|
||||||
|
|
||||||
monkeypatch.setattr(upload_page, "create_upload_job", fake_create_upload_job)
|
|
||||||
|
|
||||||
with pytest.raises(UploadError):
|
|
||||||
upload_page.submit_upload(filename="bad.jpg", file_bytes=b"")
|
|
||||||
|
|||||||
@@ -173,6 +173,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "appnope"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asttokens"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asyncpg"
|
name = "asyncpg"
|
||||||
version = "0.31.0"
|
version = "0.31.0"
|
||||||
@@ -240,6 +258,63 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cffi"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfgv"
|
name = "cfgv"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
@@ -270,6 +345,45 @@ 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" },
|
{ 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 = "comm"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "debugpy"
|
||||||
|
version = "1.8.21"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "decorator"
|
||||||
|
version = "5.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "distlib"
|
name = "distlib"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -288,6 +402,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "executing"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.138.1"
|
version = "0.138.1"
|
||||||
@@ -404,57 +527,57 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.5.2"
|
version = "3.5.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" }
|
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 = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/7a/6bc2a7835731387ed303b9390ce68a116ab053df05450a59181239200454/greenlet-3.5.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f", size = 288351, upload-time = "2026-06-17T17:36:17.019Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/1b/bd98062fcef6d0e9d0873ab6f2d029772e6ea342972ae43275bd6177900f/greenlet-3.5.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742", size = 604273, upload-time = "2026-06-17T18:07:20.296Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/e6/fe392c522bf45d976abe7db2793f6ef4e87b053ebb869deeaae46aeb54da/greenlet-3.5.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea", size = 616536, upload-time = "2026-06-17T18:29:43.205Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/4a/399ff81fa93a19d6a9df394cef0355f082dbc19ad41aba9593cd0ad444e2/greenlet-3.5.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3", size = 613749, upload-time = "2026-06-17T17:39:28.148Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/75/f519593f12ad43d08e28c03a95cfe2eeae011707dbc9dab0c4a263ce90f9/greenlet-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094", size = 1573725, upload-time = "2026-06-17T18:22:12.023Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/bc/bc1ea4b0754c6c51bbf9d94677b0b1f7fbda8cbb404e44a896854fc0a940/greenlet-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad", size = 1638132, upload-time = "2026-06-17T17:40:06.971Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/c0/f0f5a34247df60de285f75f22e57f14027f4b3c43820981854b5b643ca6d/greenlet-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146", size = 239393, upload-time = "2026-06-17T17:33:47.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/17/a8544e165445f30aea67a8d9cf2786d2bb0eb1b0e0d224b4d9bd80e2d587/greenlet-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9", size = 237723, upload-time = "2026-06-17T17:36:47.776Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/3c/bb37b9d40d65b0741a8b040ca5c307034d0a9822994dff5f825c88dd7a6b/greenlet-3.5.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34", size = 287178, upload-time = "2026-06-17T17:35:25.132Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/a6/0c5902393f492f8ceb19d0b5cf139284e3a11b333a049739643b1036b6f8/greenlet-3.5.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a", size = 606900, upload-time = "2026-06-17T18:07:21.692Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/7c/42899c31d4b87148ae4e3f87f63e13398824be6241f4dde42ded95768a34/greenlet-3.5.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7", size = 619265, upload-time = "2026-06-17T18:29:44.837Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/52/4ff8c98d3cfe62b4515f8584ae14510a58f35c549cc5292b78d9b7a40b70/greenlet-3.5.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473", size = 616187, upload-time = "2026-06-17T17:39:29.473Z" },
|
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/a6/269c8bf9aefc13361ce1088f0e392b154cb21005de7862e42b5d782b81fd/greenlet-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5", size = 1573778, upload-time = "2026-06-17T18:22:13.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/9b/391d015cbc6323e81b14c02cf825fdca7e0049c9bb489bf4ac72883118ba/greenlet-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5", size = 1638092, upload-time = "2026-06-17T17:40:08.163Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/53/5b4df711f4356c62e85d9f819d87966d526d1cfb32bae49a8f7d6fc36ea4/greenlet-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c", size = 239352, upload-time = "2026-06-17T17:38:51.593Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/b6/18efc3a329ec035c3f344b8f2b60356451950ddf9b7b64ff00023778a1dd/greenlet-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4", size = 237635, upload-time = "2026-06-17T17:35:36.632Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/89/aaafc8e14de4ac882e02ccb963225329b0e8578aba4365e71eb678e45722/greenlet-3.5.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", size = 287676, upload-time = "2026-06-17T17:33:31.514Z" },
|
{ 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/b8/fc/2308249206c12ac70de7b9a00970f84f07d10b3cd60e05d2fbcaa84124e8/greenlet-3.5.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", size = 653552, upload-time = "2026-06-17T18:07:23.493Z" },
|
{ 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/7c/24/47730d1f8f1336b9b089237521ed7a26eee997065dcb4cab81cdca333abc/greenlet-3.5.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", size = 665756, upload-time = "2026-06-17T18:29:46.616Z" },
|
{ 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/99/69/d6c99db15dc0b5e892ac3cc7b942c8b21f4a9cc3bd9ea0bc3b0f339ffbd4/greenlet-3.5.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", size = 663228, upload-time = "2026-06-17T17:39:31.073Z" },
|
{ 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/4f/88/9e603f448e2bc107c883e95817b980fb9b45ba6aea0299b2e9978124bea2/greenlet-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", size = 1620723, upload-time = "2026-06-17T18:22:14.817Z" },
|
{ 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/11/91/26da17e3777858c16fdb8d020a4c68f3a03cb92f238de8f5351d5d5186e9/greenlet-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", size = 1684227, upload-time = "2026-06-17T17:40:09.536Z" },
|
{ 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/2d/44/b3a11f7aa34cb38f1b7f3df8bcd9fcd09bac9d342c2a2c9b8686c804bcd2/greenlet-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", size = 240257, upload-time = "2026-06-17T17:35:23.359Z" },
|
{ 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/de/e3/3b62145fe917311732041a258adb218248add00542e3131c48bd047fbed5/greenlet-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e", size = 239038, upload-time = "2026-06-17T17:37:56.792Z" },
|
{ 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/47/ac/d3bad483e9f6cd1848604fdffa32cac25846dd6dfcec0e6f81c790185518/greenlet-3.5.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", size = 295668, upload-time = "2026-06-17T17:36:02.293Z" },
|
{ 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/e9/3a7e557b895fd0469b00cd0b2bd498ba950e8bfdf6d7adeecf2c5e4130a6/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", size = 652820, upload-time = "2026-06-17T18:07:24.95Z" },
|
{ 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/78/67/6225d5c5e4afc04be0fd161eec82e4b72017e8a100d222f25d7b42b0140d/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", size = 658697, upload-time = "2026-06-17T18:29:48.365Z" },
|
{ 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/fa/99/6324b8ef916dcaddccb340b304c992ca3f947614ce0f2685d438187300b8/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", size = 656436, upload-time = "2026-06-17T17:39:32.509Z" },
|
{ 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/a9/ee/f5bf9daac27c5e1b011965f64b5630a32b415daf7381b312943629e12c2a/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", size = 1617193, upload-time = "2026-06-17T18:22:16.252Z" },
|
{ 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/21/b05d5b12715bda92ce27c118d64971d21e9b8f3563ed959a7d271e2d4223/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", size = 1677512, upload-time = "2026-06-17T17:40:10.771Z" },
|
{ 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/b8/97/1b8f1314b868041b327dc1051603e8142b826480cb0ecb8a7b7632aee9c4/greenlet-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", size = 243145, upload-time = "2026-06-17T17:34:37.502Z" },
|
{ 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/36/07/1b5311775e04c718a118c504d7a3a312430e2a1bd1347226aff4774e4549/greenlet-3.5.2-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", size = 288315, upload-time = "2026-06-17T17:34:34.04Z" },
|
{ 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/ed/cc/6abcd2a486b58b9f77b7a93b690d59cb2c11a5906ed2ad4c63c7b9c1113d/greenlet-3.5.2-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", size = 659130, upload-time = "2026-06-17T18:07:26.354Z" },
|
{ 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/f2/12/f4aaad6d3d383233f700ab322568a4f29f2c701a4861d85f4811d99689b2/greenlet-3.5.2-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", size = 669724, upload-time = "2026-06-17T18:29:50.13Z" },
|
{ 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/91/2a/a089811fc31c6bf8742f40a4e73470d6d401cef18e4314eb20dc399b377c/greenlet-3.5.2-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", size = 668089, upload-time = "2026-06-17T17:39:33.808Z" },
|
{ 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/0f/1c/2f47c7d5fcfa98a62b705bf9a0505d86f4563c0d81cab1f7159ff1e743b7/greenlet-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", size = 1625684, upload-time = "2026-06-17T18:22:17.664Z" },
|
{ 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/b9/bf/661dd24624f70b7b32972d7693d0344ecde10278f647d7b828baf739899c/greenlet-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", size = 1688043, upload-time = "2026-06-17T17:40:12.403Z" },
|
{ 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/60/49/d9bde1d15a21296b3b521fe083eb8aabd54ac05d15de9832918f3d639543/greenlet-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", size = 240531, upload-time = "2026-06-17T17:35:47.448Z" },
|
{ 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/7f/4d/86d7768bd53e9907de0333df215c2018cd01a593b3715cbd79aa82dd94b7/greenlet-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9", size = 239579, upload-time = "2026-06-17T17:39:39.954Z" },
|
{ 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/92/15/907be5e8900901039bae752fa9a31c03a3c1e064833f35a4e49449184581/greenlet-3.5.2-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", size = 296697, upload-time = "2026-06-17T17:37:15.887Z" },
|
{ 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/95/5c/08c57be575c3d6a3c023bbf22144a1c7dc6ed4d134527bb36ded4dbf04a8/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", size = 656710, upload-time = "2026-06-17T18:07:28.046Z" },
|
{ 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/8c/d0/749f917bdc9fc90fceea4aa65fbf6556e617a50714d1496bdc8ad190bb36/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c", size = 662629, upload-time = "2026-06-17T18:29:51.728Z" },
|
{ 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/5a/a5/68cefae3a07f6d0093a490cf28ab604f14578f3e60205a2a2b2d5cd70af2/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", size = 660147, upload-time = "2026-06-17T17:39:35.068Z" },
|
{ 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/d2/6b/b9156d8397e4750220f54c7c5c34650f1e740a8d2f66eab9cfd1b7b53b69/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", size = 1621675, upload-time = "2026-06-17T18:22:18.873Z" },
|
{ 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/b0/e3/d3250f4fa01c211a93d04e34fded63187e648dbec17b9b1a14d388040593/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", size = 1680577, upload-time = "2026-06-17T17:40:14.055Z" },
|
{ 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/55/ba/eaee8bda4419770d7096b5a009ebff0ab20a2a28cdd83c4b591bfdf36fa9/greenlet-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", size = 243482, upload-time = "2026-06-17T17:37:34.741Z" },
|
{ 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/37/45/f794a81c91e9942c61f9110bd1f9a38a0ea565eab57f8b08cd53d3131e48/greenlet-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", size = 242062, upload-time = "2026-06-17T17:35:39.814Z" },
|
{ 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]]
|
[[package]]
|
||||||
@@ -479,6 +602,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore2"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "h11" },
|
||||||
|
{ name = "truststore" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httptools"
|
name = "httptools"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -530,6 +666,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx2"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "httpcore2" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "truststore" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "identify"
|
name = "identify"
|
||||||
version = "2.6.19"
|
version = "2.6.19"
|
||||||
@@ -566,6 +718,80 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipykernel"
|
||||||
|
version = "7.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "appnope", marker = "sys_platform == 'darwin'" },
|
||||||
|
{ name = "comm" },
|
||||||
|
{ name = "debugpy" },
|
||||||
|
{ name = "ipython" },
|
||||||
|
{ name = "jupyter-client" },
|
||||||
|
{ name = "jupyter-core" },
|
||||||
|
{ name = "matplotlib-inline" },
|
||||||
|
{ name = "nest-asyncio2" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "psutil" },
|
||||||
|
{ name = "pyzmq" },
|
||||||
|
{ name = "tornado" },
|
||||||
|
{ name = "traitlets" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipython"
|
||||||
|
version = "9.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "decorator" },
|
||||||
|
{ name = "ipython-pygments-lexers" },
|
||||||
|
{ name = "jedi" },
|
||||||
|
{ name = "matplotlib-inline" },
|
||||||
|
{ name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||||
|
{ name = "prompt-toolkit" },
|
||||||
|
{ name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "stack-data" },
|
||||||
|
{ name = "traitlets" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipython-pygments-lexers"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipywidgets"
|
||||||
|
version = "8.1.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "comm" },
|
||||||
|
{ name = "ipython" },
|
||||||
|
{ name = "jupyterlab-widgets" },
|
||||||
|
{ name = "traitlets" },
|
||||||
|
{ name = "widgetsnbextension" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itsdangerous"
|
name = "itsdangerous"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -575,6 +801,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jedi"
|
||||||
|
version = "0.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "parso" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jinja2"
|
name = "jinja2"
|
||||||
version = "3.1.6"
|
version = "3.1.6"
|
||||||
@@ -596,6 +834,45 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/55/8a/1270a6803bd821cbfcdda387eaa13cb41a7b1f7b9bd145979b3bfb9d6cb7/jsonpath_python-1.1.6-py3-none-any.whl", hash = "sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4", size = 14453, upload-time = "2026-05-07T01:26:33.306Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/8a/1270a6803bd821cbfcdda387eaa13cb41a7b1f7b9bd145979b3bfb9d6cb7/jsonpath_python-1.1.6-py3-none-any.whl", hash = "sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4", size = 14453, upload-time = "2026-05-07T01:26:33.306Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jupyter-client"
|
||||||
|
version = "8.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jupyter-core" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "pyzmq" },
|
||||||
|
{ name = "tornado" },
|
||||||
|
{ name = "traitlets" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jupyter-core"
|
||||||
|
version = "5.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "traitlets" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jupyterlab-widgets"
|
||||||
|
version = "3.0.16"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lxml"
|
name = "lxml"
|
||||||
version = "6.1.1"
|
version = "6.1.1"
|
||||||
@@ -688,6 +965,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6a/bd/6e2b76a6c5dee10397db9c929f0c5066766ec1036046f0335b7ca7ca08b8/lxml_html_clean-0.4.5-py3-none-any.whl", hash = "sha256:c76fcadd1e5bfb9b8bafc2200d51e4e78eb0dad67f56881c21dfb6484c7e7746", size = 14573, upload-time = "2026-05-20T12:17:52.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/bd/6e2b76a6c5dee10397db9c929f0c5066766ec1036046f0335b7ca7ca08b8/lxml_html_clean-0.4.5-py3-none-any.whl", hash = "sha256:c76fcadd1e5bfb9b8bafc2200d51e4e78eb0dad67f56881c21dfb6484c7e7746", size = 14573, upload-time = "2026-05-20T12:17:52.215Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown2"
|
name = "markdown2"
|
||||||
version = "2.5.5"
|
version = "2.5.5"
|
||||||
@@ -760,6 +1049,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matplotlib-inline"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "traitlets" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multidict"
|
name = "multidict"
|
||||||
version = "6.7.1"
|
version = "6.7.1"
|
||||||
@@ -859,6 +1169,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nest-asyncio2"
|
||||||
|
version = "1.7.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nicegui"
|
name = "nicegui"
|
||||||
version = "3.13.0"
|
version = "3.13.0"
|
||||||
@@ -907,7 +1226,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openrouter"
|
name = "openrouter"
|
||||||
version = "0.10.3"
|
version = "0.10.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpcore" },
|
{ name = "httpcore" },
|
||||||
@@ -915,9 +1234,9 @@ dependencies = [
|
|||||||
{ name = "jsonpath-python" },
|
{ name = "jsonpath-python" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/76/a9/b1b0a8e9902ed16b53900a153939548bb861ede0cd2a702b67ced4dacd91/openrouter-0.10.3.tar.gz", hash = "sha256:bccf916c255a48dae2a13cdb013b074d32f1042debc865130401c79a2509408b", size = 338953, upload-time = "2026-06-26T00:22:42.933Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b7/48/f886519becb38ef4176e678210618b9d9aee16317f48a3c552c03e389269/openrouter-0.10.7.tar.gz", hash = "sha256:b4ceb79b239aecda54a87742b936ded92372d67bbb99d15fa14d7cdb47f80e4b", size = 340028, upload-time = "2026-06-26T23:50:35.165Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/05/8b2c22a3ece313736129abd59e14e9fa628ba9ebd7990be65df06a603205/openrouter-0.10.3-py3-none-any.whl", hash = "sha256:e55e0b5a001e17b25050a9223e0e93a748e2dcc2de2872051ba69cec9464f539", size = 746905, upload-time = "2026-06-26T00:22:41.621Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/9b/e2ff034c97342837920b5517a59255a71e914ddb32b85b32ad4b64927219/openrouter-0.10.7-py3-none-any.whl", hash = "sha256:e08d67d49f272c682eeca4c1cc7cf8c6f807b5a57e58896fa36dc350edfc093a", size = 748380, upload-time = "2026-06-26T23:50:33.773Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -982,6 +1301,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parso"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pexpect"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "ptyprocess" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.10.0"
|
version = "4.10.0"
|
||||||
@@ -1016,6 +1356,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prompt-toolkit"
|
||||||
|
version = "3.0.52"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "wcwidth" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -1110,6 +1462,34 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutil"
|
||||||
|
version = "7.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psycopg2-binary"
|
name = "psycopg2-binary"
|
||||||
version = "2.9.12"
|
version = "2.9.12"
|
||||||
@@ -1151,6 +1531,33 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ptyprocess"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pure-eval"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pycparser"
|
||||||
|
version = "3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.4"
|
version = "2.13.4"
|
||||||
@@ -1293,6 +1700,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-discovery"
|
name = "python-discovery"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -1400,6 +1819,62 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyzmq"
|
||||||
|
version = "27.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "implementation_name == 'pypy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "15.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.20"
|
version = "0.15.20"
|
||||||
@@ -1437,6 +1912,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.51"
|
version = "2.0.51"
|
||||||
@@ -1492,6 +1976,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/cf/7d/b9813a582d4eb310be35e1fc7dfaae71207d7b62e9e53be314ebd251b53b/sqlmodel-0.0.39-py3-none-any.whl", hash = "sha256:90ebe92ce5cc11d7fff8dc7cb594790a102333c8fe7c14865254f6fc5c939795", size = 29680, upload-time = "2026-06-25T13:01:37.494Z" },
|
{ url = "https://files.pythonhosted.org/packages/cf/7d/b9813a582d4eb310be35e1fc7dfaae71207d7b62e9e53be314ebd251b53b/sqlmodel-0.0.39-py3-none-any.whl", hash = "sha256:90ebe92ce5cc11d7fff8dc7cb594790a102333c8fe7c14865254f6fc5c939795", size = 29680, upload-time = "2026-06-25T13:01:37.494Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stack-data"
|
||||||
|
version = "0.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asttokens" },
|
||||||
|
{ name = "executing" },
|
||||||
|
{ name = "pure-eval" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -1517,6 +2015,32 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tornado"
|
||||||
|
version = "6.5.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "traitlets"
|
||||||
|
version = "5.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transcription"
|
name = "transcription"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1533,15 +2057,15 @@ dependencies = [
|
|||||||
{ name = "sqlmodel" },
|
{ name = "sqlmodel" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
{ name = "pytest" },
|
|
||||||
{ name = "pytest-asyncio" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "httpx2" },
|
||||||
|
{ name = "ipykernel" },
|
||||||
|
{ name = "ipywidgets" },
|
||||||
{ name = "pre-commit" },
|
{ name = "pre-commit" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "rich" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
{ name = "ty" },
|
{ name = "ty" },
|
||||||
]
|
]
|
||||||
@@ -1556,42 +2080,54 @@ requires-dist = [
|
|||||||
{ name = "psycopg2-binary", specifier = ">=2.9.12" },
|
{ name = "psycopg2-binary", specifier = ">=2.9.12" },
|
||||||
{ name = "pydantic", specifier = ">=2.13.4" },
|
{ name = "pydantic", specifier = ">=2.13.4" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.9.1" },
|
{ name = "pydantic-settings", specifier = ">=2.9.1" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25" },
|
|
||||||
{ name = "sqlmodel", specifier = ">=0.0.25" },
|
{ name = "sqlmodel", specifier = ">=0.0.25" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "httpx2", specifier = ">=2.5.0" },
|
||||||
|
{ name = "ipykernel", specifier = ">=7.3.0" },
|
||||||
|
{ name = "ipywidgets", specifier = ">=8.1.8" },
|
||||||
{ name = "pre-commit", specifier = ">=4.6.0" },
|
{ name = "pre-commit", specifier = ">=4.6.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=0.25" },
|
||||||
|
{ name = "rich", specifier = ">=15.0.0" },
|
||||||
{ name = "ruff", specifier = ">=0.15.20" },
|
{ name = "ruff", specifier = ">=0.15.20" },
|
||||||
{ name = "ty", specifier = ">=0.0.54" },
|
{ name = "ty", specifier = ">=0.0.54" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "truststore"
|
||||||
version = "0.0.54"
|
version = "0.10.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/c6/2ea90406d82cf82b0a68725130da2cc9de161bfa883c7dee4f0d94dbf3ce/ty-0.0.54.tar.gz", hash = "sha256:b6b3cfe174f27744413c898b2488ca52ea76070637095de131698e506b455055", size = 6009000, upload-time = "2026-06-25T17:53:17.098Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/26/a83e688e108a29a8630d7075bae47b72614cfa275c88b3166575ca0a8af0/ty-0.0.54-py3-none-linux_armv6l.whl", hash = "sha256:0365ea133d6b028952c22e6412a00da9739bc3b538df3c0a61972bf64d8558f1", size = 11616164, upload-time = "2026-06-25T17:52:29.174Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/89/9f3374f9eef0267aed5efbd5544e47cfa6affc94236d929e878a780cf7d7/ty-0.0.54-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0de0cf48918609a3996cc2a4e18e8028fc4d2446bb82df822e9737f53bb9afee", size = 11351250, upload-time = "2026-06-25T17:52:32.181Z" },
|
]
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/11/e0e3f542a6de0e0dd5f503215527f8d9fc141426bd52dbe98fce927ff831/ty-0.0.54-py3-none-macosx_11_0_arm64.whl", hash = "sha256:57c7c6c1fcd2aa29cd40117142c0d45a2b0e6c42817ef58ea8dcb5f59d6ea802", size = 10872162, upload-time = "2026-06-25T17:52:35.206Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/b0/fa369840f1ccd391971f693a8f86c445eb1a0fad61a6e0e773f0871a7a6f/ty-0.0.54-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc179be5a070c5dc7785a5e108eece2ab216978a91f91e11661308749a6c28ee", size = 11419826, upload-time = "2026-06-25T17:52:37.942Z" },
|
[[package]]
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/5f/8fbf9b7147cf08cc96cc9f84b9699ae7c7a0a1eb0a4c3d912261351b1cbb/ty-0.0.54-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a0a764e2261f9292adf14bbc91b9d7ef7ee7c00b8f3d9f2563f19ae3605109b", size = 11411732, upload-time = "2026-06-25T17:52:41.124Z" },
|
name = "ty"
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/ce/53aa78828f98ce396d5cc0f0878d990c0d70f6bf7704105232399315952f/ty-0.0.54-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2259e1a1f744a5f20f079dcc4061c1464001c75f255aaca7316e422f8f5e5e09", size = 12039318, upload-time = "2026-06-25T17:52:43.981Z" },
|
version = "0.0.55"
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/05/5654d4d20376a73cd2e9df8178515c466ff9b2bda15748ee5810c955a265/ty-0.0.54-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8674617617399d4fdd568b3e3a8f87a913df525c80691d732b0744b571b341c8", size = 12625562, upload-time = "2026-06-25T17:52:46.578Z" },
|
source = { registry = "https://pypi.org/simple" }
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/f7/982be4abde816c821c6aeaba133dc34b5346eebbb29dadbf77b8ee46dad9/ty-0.0.54-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e949f7bbfa80a9fa00968924028aa673a505faa234b0b9d1241b7d34373eae14", size = 12175175, upload-time = "2026-06-25T17:52:49.176Z" },
|
sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" }
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/8b/371fa3d121a1d9c8806a997ec5aa7b6a91912044d936594e6cf04d197471/ty-0.0.54-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a0ce5af9eefe636888377d8ab8ea817239ed96de64fede62c4e5331ae16db5c", size = 11945921, upload-time = "2026-06-25T17:52:51.983Z" },
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/76/cab1ff8f6029b657b3fed59add1a2f188df6af26b6d649a6c789139dd88d/ty-0.0.54-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ad329d1942e23ee428948e673b2a50249f27e164220075b0a2ef8169029c8423", size = 12269965, upload-time = "2026-06-25T17:52:54.697Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/2f/c14b36cacacf7cc5ae40c862c34c07fadeba85cb93fd8f328cdac2029270/ty-0.0.54-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6e43124e6ac4cb9702d99facb28c6b2f2574c8a4b8f59eae2632eef4962e3439", size = 11370742, upload-time = "2026-06-25T17:52:57.233Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/1b/9e55bb273d3ea8efe165a73e34ae92220593e4dbfe665b8283be9d3eadea/ty-0.0.54-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:994ede70fc1b6f0efc29d0f1fc0d819630bed1d2610eac2e9dabb2840f3f3bf9", size = 11432346, upload-time = "2026-06-25T17:52:59.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/16/402e8d2b1ab1020a25774f22ec0ba4378617758197278c1ddbc093b64854/ty-0.0.54-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b1d048df26abb433acc5b3bcef214167c7dc9e3341f8f200a3c59702516a0ed0", size = 11707341, upload-time = "2026-06-25T17:53:02.678Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/ca/220599403e1dc864109402c88e22e8793108ec3be41576cd044772023d94/ty-0.0.54-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:651e90094d41c4add3d616e0f2c1c881c6700a2ae3ac191c023c665050ff2cdc", size = 12053427, upload-time = "2026-06-25T17:53:05.983Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/a9/f08df843e3d4422184a847829eb103c49a6118eed7024969a1a706de1c5e/ty-0.0.54-py3-none-win32.whl", hash = "sha256:7e876c9b5130afc6b6e46035a2eecca9b563b78249030296e1089922428d5415", size = 11044018, upload-time = "2026-06-25T17:53:09.582Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/26/230719333d79fae599e3c2da5ac35e36786cdd6c4b4f2a5d5450ce171dc7/ty-0.0.54-py3-none-win_amd64.whl", hash = "sha256:f478476f3222807b4d92f15c5298a8c242f2ed17c724da75f4a8c6b522b4f29a", size = 12107765, upload-time = "2026-06-25T17:53:12.2Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/68/b5fcb35ceebffab3e8898bd25e51d0bb77a0c43c50b7a8e3fae56125e4f5/ty-0.0.54-py3-none-win_arm64.whl", hash = "sha256:01ab9eb8c0802d35ae73fa08e4e037963250ee5ee6aa7ed8c8b994e5495db5ef", size = 11498510, upload-time = "2026-06-25T17:53:14.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1772,6 +2308,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wcwidth"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webencodings"
|
name = "webencodings"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -1826,6 +2371,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "widgetsnbextension"
|
||||||
|
version = "4.0.15"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wsproto"
|
name = "wsproto"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user