diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 07d9c22..0b5cf27 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -21,7 +21,8 @@ jobs: video_recombiner: ${{ steps.changes.outputs.video_recombiner }} video_upload: ${{ steps.changes.outputs.video_upload }} video_status: ${{ steps.changes.outputs.video_status }} - shared: ${{ steps.changes.outputs.shared }} + shared_go: ${{ steps.changes.outputs.shared_go }} + shared_python: ${{ steps.changes.outputs.shared_python }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -40,8 +41,10 @@ jobs: - 'backend/video-upload/**' video_status: - 'backend/video-status/**' - shared: - - 'backend/shared/**' + shared_go: + - 'backend/shared/go/**' + shared_python: + - 'backend/shared/python/**' frontend-ci: needs: path-filter if: ${{ github.event_name == 'pull_request' && needs.path-filter.outputs.frontend == 'true' }} @@ -226,9 +229,9 @@ jobs: working-directory: ./backend/video-status/cmd run: make test_all - shared-ci: + shared-go-ci: needs: path-filter - if: ${{ github.event_name == 'pull_request' && needs.path-filter.outputs.shared == 'true' }} + if: ${{ github.event_name == 'pull_request' && needs.path-filter.outputs.shared_go == 'true' }} runs-on: ubuntu-latest steps: - name: Checkout repository @@ -241,19 +244,55 @@ jobs: cache: 'true' - name: Download Go modules - working-directory: ./backend/shared + working-directory: ./backend/shared/go run: go mod download - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: version: v2.11 - working-directory: ./backend/shared + working-directory: ./backend/shared/go - name: run tests - working-directory: ./backend/shared + working-directory: ./backend/shared/go run: make test_all + shared-python-ci: + needs: path-filter + if: ${{ github.event_name == 'pull_request' && needs.path-filter.outputs.shared_python == 'true' }} + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.8.19" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install the project + run: uv sync --locked --all-extras --dev + working-directory: ./backend/shared/python + + - name: Run Linter and Formatting + run: | + uv run ruff check . + uv run ruff format --check + working-directory: ./backend/shared/python + + - name: Run Type Checking + run: uv run pyrefly check + working-directory: ./backend/shared/python + + - name: Run Unit/Integration Tests + run: make test_all + working-directory: ./backend/shared/python + coverage: needs: path-filter runs-on: ubuntu-latest @@ -264,7 +303,8 @@ jobs: needs.path-filter.outputs.video_recombiner == 'true' || needs.path-filter.outputs.video_upload == 'true' || needs.path-filter.outputs.video_status == 'true' || - needs.path-filter.outputs.shared == 'true' + needs.path-filter.outputs.shared_go == 'true' || + needs.path-filter.outputs.shared_python == 'true' ) steps: - uses: actions/checkout@v4 @@ -301,8 +341,12 @@ jobs: working-directory: ./backend/video-status/cmd run: make coverage - - name: Run shared coverage - working-directory: ./backend/shared + - name: Run shared go coverage + working-directory: ./backend/shared/go + run: make coverage + + - name: Run shared python coverage + working-directory: ./backend/shared/python run: make coverage - name: Upload coverage to Codecov @@ -313,6 +357,7 @@ jobs: ./backend/video-recombiner/cmd/coverage.out, ./backend/video-upload/cmd/coverage.out, ./backend/video-status/cmd/coverage.out, - ./backend/shared/coverage.out, - ./backend/scene-detector/coverage.xml + ./backend/scene-detector/coverage.xml, + ./backend/shared/go/coverage.out, + ./backend/shared/python/coverage.out token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5c77f24..6bb493f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ docs/ __pycache__/ .pytest_cache/ .ruff_cache/ +shared_python.egg-info # Logs logs diff --git a/backend/go.work b/backend/go.work index e4bfc92..190730b 100644 --- a/backend/go.work +++ b/backend/go.work @@ -1,7 +1,7 @@ go 1.26.2 use ( - ./shared + ./shared/go ./pipeline-tests ./transcoder-worker ./video-recombiner diff --git a/backend/go.work.sum b/backend/go.work.sum index 1273122..3c0a0ed 100644 --- a/backend/go.work.sum +++ b/backend/go.work.sum @@ -40,6 +40,7 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= diff --git a/backend/pipeline-tests/e2e_integration_test.go b/backend/pipeline-tests/e2e_integration_test.go index b311fc3..d5e1d9c 100644 --- a/backend/pipeline-tests/e2e_integration_test.go +++ b/backend/pipeline-tests/e2e_integration_test.go @@ -9,6 +9,8 @@ import ( "os/exec" "path/filepath" "pipeline-tests/helpers" + + shelpers "shared/test" "testing" "time" "github.com/stretchr/testify/assert" @@ -18,7 +20,7 @@ import ( var sharedFilerURL string func TestMain(m *testing.M) { - filerURL, cleanup := helpers.StartSeaweedFSFiler() + filerURL, cleanup := shelpers.StartSeaweedFSFiler() sharedFilerURL = filerURL code := m.Run() diff --git a/backend/pipeline-tests/helpers/storage.go b/backend/pipeline-tests/helpers/storage.go deleted file mode 100644 index d7d4667..0000000 --- a/backend/pipeline-tests/helpers/storage.go +++ /dev/null @@ -1,39 +0,0 @@ -//go:build integration - -package helpers - -import ( - "context" - - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" -) - -func StartSeaweedFSFiler() (string, func()) { - ctx := context.Background() - - req := testcontainers.ContainerRequest{ - Image: "chrislusf/seaweedfs", - Cmd: []string{"server", "-dir=/data", "-master.port=9333", "-volume.port=8080", "-filer"}, - ExposedPorts: []string{"9333/tcp", "8888/tcp"}, - WaitingFor: wait.ForAll( - wait.ForHTTP("/dir/status").WithPort("9333/tcp").WithStatusCodeMatcher(func(status int) bool { return status < 500 }), - wait.ForHTTP("/").WithPort("8888/tcp").WithStatusCodeMatcher(func(status int) bool { return status < 500 }), - ), - } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - panic("failed to start SeaweedFS container: " + err.Error()) - } - - endpoint, err := container.PortEndpoint(ctx, "8888/tcp", "http") - if err != nil { - panic("failed to get SeaweedFS filer endpoint: " + err.Error()) - } - - return endpoint, func() { _ = container.Terminate(ctx) } -} diff --git a/backend/scene-detector/pyproject.toml b/backend/scene-detector/pyproject.toml index 1b4424e..1afc635 100644 --- a/backend/scene-detector/pyproject.toml +++ b/backend/scene-detector/pyproject.toml @@ -10,8 +10,12 @@ dependencies = [ "scenedetect[opencv-headless]>=0.6.7.1", "structlog>=25.5.0", "types-requests>=2.33.0.20260402", + "shared_python" ] +[tool.uv.sources] +shared_python = { path = "../shared/python", editable = true } + [tool.pyright] venvPath = "." venv = ".venv" @@ -22,7 +26,8 @@ extraPaths = ["."] [tool.pyrefly] python-version = "3.13" untyped-def-behavior = "check-and-infer-return-any" -search-path = ["."] +search-path = [".", "../shared/python/src"] +venv = ".venv" [tool.pyrefly.errors] unannotated-parameter = "error" diff --git a/backend/scene-detector/src/core/settings.py b/backend/scene-detector/src/core/settings.py index a95a930..c4dcdff 100644 --- a/backend/scene-detector/src/core/settings.py +++ b/backend/scene-detector/src/core/settings.py @@ -7,24 +7,13 @@ class Settings(BaseSettings): # general config - LOG_LEVEL: str = "DEBUG" - LOG_FORMAT: str = "json" HTTP_PORT: int = 9098 + SERVICE_NAME: str = "scene-detector" # Nats config - NATS_URL: str = "nats://localhost:4222" - NATS_SUB_QUEUE_NAME: str = "scene-detector-workers" - SCENE_SPLIT_SUBJECT: str = ( - "jobs.video.scene-split" # topic containing Job ID + storage path in MinIO - ) - VIDEO_CHUNKS_SUBJECT: str = "jobs.video.chunks" - - MAX_RECONNECT_ATTEMPT: int = 5 - RECONNECT_TIME_WAIT_S: int = 2 - - MAX_DELIVER_ATTEMPTS: int = 3 - ACK_WAIT_S: int = 30 - KV_BUCKET_TTL_S: int = 3 * 60 * 60 # 3 hour TTL + SUB_QUEUE_NAME: str = "scene-detector-workers" + SUB_SUBJECT: str = "jobs.video.scene-split" + PUB_SUBJECT: str = "jobs.video.chunks" BASE_STORAGE_URL: str = "http://localhost:8888" diff --git a/backend/scene-detector/src/handler/connection.py b/backend/scene-detector/src/handler/connection.py deleted file mode 100644 index 51eac6b..0000000 --- a/backend/scene-detector/src/handler/connection.py +++ /dev/null @@ -1,38 +0,0 @@ -from ..core.logging import logger -from ..core.settings import settings -from nats.js.client import JetStreamContext -from nats.aio.client import Client as NATSClient - - -async def nats_connect() -> tuple[NATSClient, JetStreamContext]: - """nats connection and jetstream context required for pub/sub""" - nats_url = settings.NATS_URL # the nats server url - - nats_client = NATSClient() - await nats_client.connect( - nats_url, - max_reconnect_attempts=settings.MAX_RECONNECT_ATTEMPT, - reconnect_time_wait=settings.RECONNECT_TIME_WAIT_S, - reconnected_cb=_on_reconnect, - disconnected_cb=_on_disconnect, - error_cb=_on_error, - ) - - jetstream_client: JetStreamContext = nats_client.jetstream() - - return nats_client, jetstream_client - - -async def _on_reconnect() -> None: - """callback function for logging reconnection""" - logger.debug("reconnected to nats") - - -async def _on_disconnect() -> None: - """callback function for logging disconnect""" - logger.warning("disconnected from nats") - - -async def _on_error(err: Exception) -> None: - """callback function for logging error connecting to nats""" - logger.error("error connecting to nats", err=str(err)) diff --git a/backend/scene-detector/src/handler/publisher.py b/backend/scene-detector/src/handler/publisher.py deleted file mode 100644 index 67a698a..0000000 --- a/backend/scene-detector/src/handler/publisher.py +++ /dev/null @@ -1,46 +0,0 @@ -from nats.js.client import JetStreamContext -from nats.errors import TimeoutError -from nats.js.errors import APIError -from ..core.logging import logger -from ..core.settings import settings -from .messages import VideoChunkMessage - - -async def scene_video_chunks( - js: JetStreamContext, msgs: list[VideoChunkMessage] -) -> None: - """ - Publishes video split by scene ready message to nats jetstream - - Args: - js: the jetstream context with connection info for publishing - msgs: the actual data we are publishing to the broker - - Raises: - TimeoutError: when publishing times out, logs and raises - APIError: when an jetstream api error is recieved when trying - to publish, logs and raises - """ - for msg in msgs: - try: - await js.publish( - subject=settings.VIDEO_CHUNKS_SUBJECT, - payload=msg.model_dump_json().encode(), - ) - logger.debug("pub msg to nats jetstream successfully") - except TimeoutError as e: - logger.error( - "timed out publishing chunk msg", - job_id=msg.job_id, - chunk_idex=msg.chunk_index, - err=str(e), - ) - raise - except APIError as e: - logger.error( - "jetstream error publishing chunk message", - job_id=msg.job_id, - chunk_idex=msg.chunk_index, - err=str(e), - ) - raise diff --git a/backend/scene-detector/src/handler/subscriber.py b/backend/scene-detector/src/handler/subscriber.py deleted file mode 100644 index 235ffc5..0000000 --- a/backend/scene-detector/src/handler/subscriber.py +++ /dev/null @@ -1,70 +0,0 @@ -from nats.js.errors import KeyNotFoundError -from nats.js.kv import KeyValue -from nats.js.api import ConsumerConfig -from nats.aio.msg import Msg -from ..core.logging import logger -from ..core.settings import settings -from ..processing.job import process_job -from .messages import SceneSplitMessage -from .publisher import scene_video_chunks -from nats.js.client import JetStreamContext -import json - - -async def raw_videos( - js: JetStreamContext, msg_processed_kv: KeyValue, job_status_kv: KeyValue -) -> None: - """Nats jetstream consumer that subscribes to subject to process videos""" - sub = await js.subscribe( - subject=settings.SCENE_SPLIT_SUBJECT, - durable=settings.NATS_SUB_QUEUE_NAME, - queue=settings.NATS_SUB_QUEUE_NAME, - config=ConsumerConfig( - max_deliver=settings.MAX_DELIVER_ATTEMPTS, ack_wait=settings.ACK_WAIT_S - ), - ) - - async for msg in sub.messages: - await _process_msg(js, msg_processed_kv, job_status_kv, msg) - - -async def _process_msg( - js: JetStreamContext, msg_processed_kv: KeyValue, job_status_kv: KeyValue, msg: Msg -) -> None: - """Processes a single scene-split message""" - try: - metadata = SceneSplitMessage.model_validate_json(msg.data.decode()) - - if await _is_already_processed(msg_processed_kv, metadata.job_id): - logger.debug("job already processed, skipping", job_id=metadata.job_id) - await msg.ack() - return - - await _update_job_status(job_status_kv, metadata.job_id) - - chunk_messages = await process_job(metadata) - - await scene_video_chunks(js, chunk_messages) - await msg_processed_kv.put(metadata.job_id, b"done") - await msg.ack() - except Exception as e: - logger.error("unexpected error processing job", err=str(e)) - await msg.nak() - - -async def _is_already_processed(kv: KeyValue, job_id: str) -> bool: - """Checks if the job_id exists in the scene-split-processed so it doesnt reprocess""" - try: - await kv.get(job_id) - return True - except KeyNotFoundError: - return False - - -async def _update_job_status(job_status_kv: KeyValue, job_id: str) -> None: - """Writes PROCESSING:scene-detector stage to the job-status KV bucket""" - try: - status = json.dumps({"state": "PROCESSING", "stage": "scene-detector"}).encode() - await job_status_kv.put(job_id, status) - except Exception as e: - logger.error("failed to update job status stage", job_id=job_id, err=str(e)) diff --git a/backend/scene-detector/src/processing/job.py b/backend/scene-detector/src/processing/job.py index 437009d..b128f88 100644 --- a/backend/scene-detector/src/processing/job.py +++ b/backend/scene-detector/src/processing/job.py @@ -1,15 +1,19 @@ -from ..core.logging import logger -from ..storage.queries import fetch_video -from ..storage.queries import upload_video_chunks +from shared_core.logging import get_logger +from shared_storage.queries import fetch_video +from shared_storage.queries import upload_video +from shared_handler.messages import VideoChunkMessage +from shared_handler.messages import ProcessJobMessage +from ..core.settings import settings from .video import split_into_chunks -from ..handler.messages import SceneSplitMessage -from ..handler.messages import VideoChunkMessage from scenedetect import VideoOpenFailure +import os import asyncio import shutil +logger = get_logger(settings.SERVICE_NAME) -async def process_job(metadata: SceneSplitMessage) -> list[VideoChunkMessage]: + +async def process_job(metadata: ProcessJobMessage) -> list[VideoChunkMessage]: """ takes in the msg from NATS subcriber, fetches the video from SeaweedFS, splits the video into chunks, uploads the chunks back to seaweedfs, and returns @@ -31,7 +35,9 @@ async def process_job(metadata: SceneSplitMessage) -> list[VideoChunkMessage]: temp_dir = f"../temp/{metadata.job_id}" chunks_dir = f"../temp/{metadata.job_id}/chunks" - local_video_path = await asyncio.to_thread(fetch_video, metadata.storage_url) + local_video_path = await asyncio.to_thread( + fetch_video, metadata.storage_url, settings.SERVICE_NAME + ) try: chunk_paths = await asyncio.to_thread( @@ -46,8 +52,17 @@ async def process_job(metadata: SceneSplitMessage) -> list[VideoChunkMessage]: ) raise - chunk_paths = await asyncio.to_thread( - upload_video_chunks, metadata.job_id, chunk_paths + storage_urls = await asyncio.gather( + *[ + asyncio.to_thread( + upload_video, + f"{settings.BASE_STORAGE_URL}/{metadata.job_id}/{os.path.basename(path)}", + metadata.job_id, + path, + settings.SERVICE_NAME, + ) + for path in chunk_paths + ] ) try: @@ -59,9 +74,9 @@ async def process_job(metadata: SceneSplitMessage) -> list[VideoChunkMessage]: VideoChunkMessage( job_id=metadata.job_id, chunk_index=i, - total_chunks=len(chunk_paths), - storage_url=path, + total_chunks=len(storage_urls), + storage_url=url, target_resolution=metadata.target_resolution, ) - for i, path in enumerate(chunk_paths) + for i, url in enumerate(storage_urls) ] diff --git a/backend/scene-detector/src/processing/nats_msg.py b/backend/scene-detector/src/processing/nats_msg.py new file mode 100644 index 0000000..546e501 --- /dev/null +++ b/backend/scene-detector/src/processing/nats_msg.py @@ -0,0 +1,40 @@ +from shared_handler.messages import ProcessJobMessage +from nats.js.kv import KeyValue +from nats.aio.msg import Msg +from shared_core.logging import get_logger +from shared_handler.kv import update_job_status +from shared_handler.kv import check_already_processed +from shared_handler.nats import publisher +from ..core.settings import settings +from ..processing.job import process_job +from nats.js.client import JetStreamContext + +logger = get_logger("scene-detector") + + +async def process_msg( + js: JetStreamContext, msg_processed_kv: KeyValue, job_status_kv: KeyValue, msg: Msg +) -> None: + """Processes a single scene-split message""" + try: + metadata = ProcessJobMessage.model_validate_json(msg.data.decode()) + + if await check_already_processed(msg_processed_kv, metadata.job_id): + logger.debug("job already processed, skipping", job_id=metadata.job_id) + await msg.ack() + return + + await update_job_status( + job_status_kv, metadata.job_id, settings.SERVICE_NAME, settings.SERVICE_NAME + ) + + chunk_messages = await process_job(metadata) + + for chunk_msg in chunk_messages: + await publisher(js, chunk_msg, settings.PUB_SUBJECT, settings.SERVICE_NAME) + + await msg_processed_kv.put(metadata.job_id, b"done") + await msg.ack() + except Exception as e: + logger.error("unexpected error processing job", err=str(e)) + await msg.nak() diff --git a/backend/scene-detector/src/service.py b/backend/scene-detector/src/service.py index 7b12cbc..783b525 100644 --- a/backend/scene-detector/src/service.py +++ b/backend/scene-detector/src/service.py @@ -1,55 +1,42 @@ -from src.handler.http_server import start_health_server -from nats.js.api import KeyValueConfig -from .handler.subscriber import raw_videos -from .handler.connection import nats_connect -from .storage.check_health import check_storage_health -from .core.logging import logger +from shared_handler.nats import consumer +from shared_core.logging import get_logger +from shared_handler.kv import create_kv +from shared_handler.kv import connect_kv +from shared_handler.connection import nats_connect +from shared_handler.connection import check_js_stream_exists +from shared_handler.http import start_health_server +from shared_storage.check_health import check_storage_health from .core.settings import settings -import nats.js.errors as js_errors +from .processing.nats_msg import process_msg import asyncio +logger = get_logger(settings.SERVICE_NAME) + async def start_service() -> None: """Start the python scene-detection service""" - check_storage_health() + check_storage_health(settings.SERVICE_NAME) health_server = start_health_server(settings.HTTP_PORT) - nc, js = await nats_connect() + nc, js = await nats_connect(settings.SERVICE_NAME) - try: - await js.find_stream_name_by_subject(settings.SCENE_SPLIT_SUBJECT) - except js_errors.NotFoundError: - raise RuntimeError( - f"No stream found for subscriber `{settings.SCENE_SPLIT_SUBJECT}`" - ) + await check_js_stream_exists(js, settings.SUB_SUBJECT) + await check_js_stream_exists(js, settings.PUB_SUBJECT) - try: - await js.find_stream_name_by_subject(settings.VIDEO_CHUNKS_SUBJECT) - except js_errors.NotFoundError: - raise RuntimeError( - f"No stream found for video chunks subject `{settings.VIDEO_CHUNKS_SUBJECT}`" - ) + job_status_kv = await connect_kv(js, "job-status") + msg_processed_kv = await create_kv(js, "scene-split-processed") try: - msg_processed_kv = await js.create_key_value( - config=KeyValueConfig( - bucket="scene-split-processed", - description="key value bucket for scene detector to check if the job_id already processed for idempotency", - ttl=settings.KV_BUCKET_TTL_S, - ) + await consumer( + js, + msg_processed_kv, + job_status_kv, + settings.SUB_SUBJECT, + settings.SUB_QUEUE_NAME, + settings.SUB_QUEUE_NAME, + process_msg=process_msg, ) - except js_errors.APIError as e: - raise RuntimeError(f"failed to create scene-split-processed KV bucket: {e}") - try: - job_status_kv = await js.key_value("job-status") - except js_errors.NotFoundError: - raise RuntimeError( - "job-status KV bucket not found, check video-status is running" - ) - - try: - await raw_videos(js, msg_processed_kv, job_status_kv) finally: health_server.shutdown() await nc.drain() diff --git a/backend/scene-detector/src/storage/__init__.py b/backend/scene-detector/src/storage/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/scene-detector/src/storage/queries.py b/backend/scene-detector/src/storage/queries.py deleted file mode 100644 index cb37f35..0000000 --- a/backend/scene-detector/src/storage/queries.py +++ /dev/null @@ -1,106 +0,0 @@ -from ..core.logging import logger -from ..core.settings import settings -import os -import requests - -TEMP_DIR: str = "../temp" - - -def fetch_video(storage_url: str) -> str: - """ - Fetch unprocessed video from seaweedfs storage for processing - and save it locally for processing - - Args: - storage_url: full SeaweedFS URL to the video, from NATS - - Raises: - requests.ConnectionError if SeaweedFS is unreachable - request.HTTPError: if SeaweedFS returns 404 or 5xx - - Returns: - dest_path string on success - """ - try: - response = requests.get(storage_url) - response.raise_for_status() - except requests.ConnectionError as e: - logger.error( - "could not connect to seaweedfs", storage_url=storage_url, err=str(e) - ) - raise - except requests.HTTPError as e: - logger.error( - "seaweedfs returned error fetching video", - storage_url=storage_url, - status_code=e.response.status_code, - err=str(e), - ) - raise - - parts = storage_url.rstrip("/").split("/") - dest_path: str = f"{TEMP_DIR}/{parts[-2]}/{parts[-1]}" - os.makedirs(os.path.dirname(dest_path), exist_ok=True) - with open(dest_path, "wb") as f: - f.write(response.content) - - return dest_path - - -def upload_video_chunks(job_id: str, chunk_paths: list[str]) -> list[str]: - """ - upload locally split video chunks by scenes to seaweedfs storage - - Args: - job_id: job_id for one request from NATS - chunk_paths: list of local file paths for each chunk - - Raises: - FileNotFoundError: if a local chunk file is missing before upload - requests.ConnectionError: If SeaweedFS is unreachable - requests.HTTPError: If SeaweedFS returns 4xx/5xx on any chunk upload - - Returns: - list of SeaweedFS storage URLS for uploaded chunks - """ - storage_urls: list[str] = [] - - for chunk_path in chunk_paths: - if not os.path.exists(chunk_path): - logger.error( - "chunk video file not found before upload", - chunk_path=chunk_path, - job_id=job_id, - ) - raise FileNotFoundError(f"chunk file not found: {chunk_path}") - - filename = os.path.basename(chunk_path) - url = f"{settings.BASE_STORAGE_URL}/{job_id}/{filename}" - - try: - with open(chunk_path, "rb") as f: - response = requests.put( - url, data=f, headers={"Content-Type": "application/octet-stream"} - ) - response.raise_for_status() - except requests.ConnectionError as e: - logger.error( - "could not connect to seaweedfs", url=url, job_id=job_id, err=str(e) - ) - raise - except requests.HTTPError as e: - logger.error( - "seaweedfs returned error uploading chunk", - url=url, - job_id=job_id, - status_code=e.response.status_code, - err=str(e), - ) - raise - - storage_urls.append(url) - - logger.debug( - "uploaded video chunks to seaweedfs", job_id=job_id, count=len(storage_urls) - ) - return storage_urls diff --git a/backend/scene-detector/tests/fixtures/helpers.py b/backend/scene-detector/tests/fixtures/helpers.py index ef63e7e..b3520b6 100644 --- a/backend/scene-detector/tests/fixtures/helpers.py +++ b/backend/scene-detector/tests/fixtures/helpers.py @@ -2,10 +2,10 @@ from typing import AsyncGenerator from pathlib import Path from nats.js import JetStreamContext -from unittest.mock import patch -from src.handler.http_server import start_health_server -from src.storage import queries -import socket +from unittest.mock import patch, AsyncMock, MagicMock +from nats.js.kv import KeyValue +from nats.js.errors import KeyNotFoundError +from shared_storage import queries import pytest import pytest_asyncio @@ -22,46 +22,21 @@ async def patched_start_service( ) -> AsyncGenerator[tuple[Any, JetStreamContext], None]: """Yields (nc, js) with check_storage_health, start_health_server, and nats_connect patched""" nc, js = js_context + + mock_kv = MagicMock(spec=KeyValue) + mock_kv.get = AsyncMock(side_effect=KeyNotFoundError()) + mock_kv.put = AsyncMock() + with ( patch("src.service.check_storage_health"), patch("src.service.start_health_server"), patch("src.service.nats_connect", return_value=(nc, js)), + patch("src.service.connect_kv", new_callable=AsyncMock), + patch("src.service.create_kv", return_value=mock_kv), ): yield nc, js -@pytest.fixture -def chunk_files(tmp_path: Path) -> list[str]: - """Creates a set of fake .mp4 chunk files in tmp_path""" - chunks = [] - for i in range(3): - chunk = tmp_path / f"video-Scene-{i + 1:03d}.mp4" - chunk.write_bytes(b"fake chunk content") - chunks.append(str(chunk)) - return chunks - - -@pytest.fixture -def single_video_chunk(tmp_path: Path) -> str: - chunk = tmp_path / "chunk.mp4" - chunk.write_bytes(b"data") - return str(chunk) - - -def _free_port() -> int: - with socket.socket() as s: - s.bind(("", 0)) - return s.getsockname()[1] - - -@pytest.fixture -def live_http_server() -> Any: - port = _free_port() - server = start_health_server(port) - yield f"http://localhost:{port}" - server.shutdown() - - @pytest.fixture def spy_drain(js_context: tuple[Any, JetStreamContext]) -> tuple[Any, list[bool]]: """Replaces nc.drain with a no-op spy (whatever that means)""" diff --git a/backend/scene-detector/tests/fixtures/nats.py b/backend/scene-detector/tests/fixtures/nats.py index 98512a4..2c59474 100644 --- a/backend/scene-detector/tests/fixtures/nats.py +++ b/backend/scene-detector/tests/fixtures/nats.py @@ -33,7 +33,7 @@ async def js_context( pass await js.add_stream( name="videos", - subjects=[settings.SCENE_SPLIT_SUBJECT, settings.VIDEO_CHUNKS_SUBJECT], + subjects=[settings.SUB_SUBJECT, settings.PUB_SUBJECT], ) await js.create_key_value(config=KeyValueConfig(bucket="job-status")) yield nc, js @@ -42,19 +42,15 @@ async def js_context( @pytest_asyncio.fixture async def nats_video_chunks_subscriber( - js_context: tuple[Any, JetStreamContext], monkeypatch: Any + js_context: tuple[Any, JetStreamContext], ) -> AsyncGenerator[list[Any], None]: - monkeypatch.setattr( - "src.handler.publisher.settings.VIDEO_CHUNKS_SUBJECT", - settings.VIDEO_CHUNKS_SUBJECT, - ) nc, js = js_context received = [] async def handler(msg: Msg) -> None: received.append(json.loads(msg.data.decode())) - sub = await nc.subscribe(settings.VIDEO_CHUNKS_SUBJECT, cb=handler) + sub = await nc.subscribe(settings.PUB_SUBJECT, cb=handler) yield received await sub.unsubscribe() diff --git a/backend/scene-detector/tests/fixtures/storage.py b/backend/scene-detector/tests/fixtures/storage.py index 7f16ccd..9e7fa35 100644 --- a/backend/scene-detector/tests/fixtures/storage.py +++ b/backend/scene-detector/tests/fixtures/storage.py @@ -1,8 +1,7 @@ -from typing import Generator, Tuple +from typing import Generator from testcontainers.core.container import DockerContainer import requests import pytest -import uuid import time import os @@ -47,20 +46,3 @@ def seaweedfs_url() -> Generator[str, None, None]: filer_port = container.get_exposed_port(8888) _wait_for_seaweedfs(host, master_port, filer_port) yield f"http://{host}:{filer_port}" - - -@pytest.fixture -def seeded_video(seaweedfs_url: str) -> Generator[Tuple[str, str], None, None]: - """Seeds ForBiggerBlazes.mp4 into SeaweedFS and yields (job_id, storage_url)""" - job_id = str(uuid.uuid4()) - storage_url = f"{seaweedfs_url}/{job_id}/{TEST_VIDEO_FILENAME}" - - with open(TEST_VIDEO_PATH, "rb") as f: - response = requests.put( - storage_url, - data=f, - headers={"Content-Type": "application/octet-stream"}, - ) - response.raise_for_status() - - yield job_id, storage_url diff --git a/backend/scene-detector/tests/integration/test_nats_msg.py b/backend/scene-detector/tests/integration/test_nats_msg.py new file mode 100644 index 0000000..42d1b80 --- /dev/null +++ b/backend/scene-detector/tests/integration/test_nats_msg.py @@ -0,0 +1,85 @@ +from typing import Any +from unittest.mock import patch +from unittest.mock import AsyncMock +from nats.js import JetStreamContext +from nats.js.api import KeyValueConfig +from shared_handler.messages import ProcessJobMessage +from src.processing.nats_msg import process_msg +import json +import pytest +import uuid + + +@pytest.mark.asyncio +async def test_processes_published_message( + js_context: tuple[Any, JetStreamContext], +) -> None: + """Verifies process_msg parses the message and calls process_job with correct data""" + nc, js = js_context + + kv = await js.create_key_value( + config=KeyValueConfig(bucket="test-nats-msg-status-1") + ) + job_status_kv = await js.create_key_value( + config=KeyValueConfig(bucket="test-nats-msg-job-status-1") + ) + + job_id = str(uuid.uuid4()) + msg = AsyncMock() + msg.data = json.dumps( + { + "job_id": job_id, + "storage_url": "/fake/video.mp4", + "source_resolution": "280p", + "target_resolution": "480p", + } + ).encode() + + with patch( + "src.processing.nats_msg.process_job", new_callable=AsyncMock, return_value=[] + ) as mock_process: + await process_msg(js, kv, job_status_kv, msg) + + mock_process.assert_called_once_with( + ProcessJobMessage( + job_id=job_id, + storage_url="/fake/video.mp4", + source_resolution="280p", + target_resolution="480p", + ) + ) + msg.ack.assert_called_once() + + +@pytest.mark.asyncio +async def test_skips_redelivered_message_for_already_processed_job( + js_context: tuple[Any, JetStreamContext], +) -> None: + """Verifies process_msg acks and skips when job_id already exists in KV""" + nc, js = js_context + + kv = await js.create_key_value( + config=KeyValueConfig(bucket="test-nats-msg-status-2") + ) + job_status_kv = await js.create_key_value( + config=KeyValueConfig(bucket="test-nats-msg-job-status-2") + ) + await kv.put("job-already-done", b"done") + + msg = AsyncMock() + msg.data = json.dumps( + { + "job_id": "job-already-done", + "storage_url": "/fake/video.mp4", + "source_resolution": "280p", + "target_resolution": "480p", + } + ).encode() + + with patch( + "src.processing.nats_msg.process_job", new_callable=AsyncMock, return_value=[] + ) as mock_process: + await process_msg(js, kv, job_status_kv, msg) + + mock_process.assert_not_called() + msg.ack.assert_called_once() diff --git a/backend/scene-detector/tests/integration/test_start_service.py b/backend/scene-detector/tests/integration/test_start_service.py index e50e194..2c92282 100644 --- a/backend/scene-detector/tests/integration/test_start_service.py +++ b/backend/scene-detector/tests/integration/test_start_service.py @@ -2,8 +2,8 @@ from unittest.mock import patch from unittest.mock import AsyncMock from nats.js import JetStreamContext +from shared_handler.messages import VideoChunkMessage from src.service import start_service -from src.handler.messages import VideoChunkMessage from src.core.settings import settings import asyncio import json @@ -19,13 +19,7 @@ async def test_full_flow_publishes_chunks_downstream( ) -> None: """Publishes to upstream topic -> process_job runs -> chunks appear on downstream topic""" nc, js = patched_start_service - monkeypatch.setattr( - "src.handler.subscriber.settings.SCENE_SPLIT_SUBJECT", - settings.SCENE_SPLIT_SUBJECT, - ) - monkeypatch.setattr( - "src.handler.subscriber.settings.NATS_SUB_QUEUE_NAME", "test-full-flow-worker" - ) + monkeypatch.setattr("src.service.settings.SUB_QUEUE_NAME", "test-full-flow-worker") nc.drain = AsyncMock() job_id = str(uuid.uuid4()) @@ -49,14 +43,15 @@ async def test_full_flow_publishes_chunks_downstream( async def fake_process_job(_metadata: Any) -> list[VideoChunkMessage]: return fake_chunks - with patch("src.handler.subscriber.process_job", side_effect=fake_process_job): + with patch("src.processing.nats_msg.process_job", side_effect=fake_process_job): task = asyncio.create_task(start_service()) await nc.publish( - settings.SCENE_SPLIT_SUBJECT, + settings.SUB_SUBJECT, json.dumps( { "job_id": job_id, "storage_url": "/fake/video.mp4", + "source_resolution": "1080p", "target_resolution": "480p", } ).encode(), @@ -92,12 +87,12 @@ async def test_raises_runtime_error_when_video_chunks_stream_not_found( ) -> None: """Raises RuntimeError when no NATS stream covers the downstream chunks subject""" nc, js = patched_start_service - monkeypatch.setattr( - "src.service.settings.VIDEO_CHUNKS_SUBJECT", "nonexistent.subject.xyz" - ) + monkeypatch.setattr("src.service.settings.PUB_SUBJECT", "nonexistent.subject.xyz") nc.drain = AsyncMock() - with pytest.raises(RuntimeError, match="No stream found for video chunks"): + with pytest.raises( + RuntimeError, match="No stream found for `nonexistent.subject.xyz`" + ): await start_service() @@ -110,13 +105,11 @@ async def test_drain_called_in_finally_when_raw_videos_raises( nc, js = patched_start_service # this isnt used? maybe we dont need it _, called = spy_drain - async def failing_raw_videos( - _js: JetStreamContext, _kv: Any, _job_status_kv: Any - ) -> None: + async def failing_consumer(*_args: Any, **_kwargs: Any) -> None: raise RuntimeError("subscriber failed unexpectedly") with ( - patch("src.service.raw_videos", side_effect=failing_raw_videos), + patch("src.service.consumer", side_effect=failing_consumer), pytest.raises(RuntimeError, match="subscriber failed unexpectedly"), ): await start_service() @@ -133,12 +126,10 @@ async def test_drain_called_in_finally_on_cancellation( nc, js = patched_start_service # this isnt used? maybe we dont need it _, called = spy_drain - async def hanging_raw_videos( - _js: JetStreamContext, _kv: Any, _job_status_kv: Any - ) -> None: - await asyncio.sleep(30) + async def hanging_consumer(*_args: Any, **_kwargs: Any) -> None: + await asyncio.Event().wait() - with patch("src.service.raw_videos", side_effect=hanging_raw_videos): + with patch("src.service.consumer", side_effect=hanging_consumer): task = asyncio.create_task(start_service()) await asyncio.sleep(0.05) task.cancel() @@ -158,33 +149,27 @@ async def test_service_can_be_cancelled_while_process_job_is_running( """Service cancels promptly mid-processing, proving process_job does not block the event loop""" nc, _ = patched_start_service monkeypatch.setattr( - "src.handler.subscriber.settings.SCENE_SPLIT_SUBJECT", - settings.SCENE_SPLIT_SUBJECT, - ) - # Unique consumer name: prevents unacked messages from leaking into other tests' durable consumers - monkeypatch.setattr( - "src.handler.subscriber.settings.NATS_SUB_QUEUE_NAME", - "test-cancellation-worker", + "src.service.settings.SUB_QUEUE_NAME", "test-cancellation-worker" ) nc.drain = AsyncMock() processing_started = asyncio.Event() - async def slow_process_job(_metadata: Any) -> list[Any]: + async def slow_process_job(_metadata: Any) -> None: processing_started.set() - await asyncio.sleep(30) - return [] + await asyncio.Event().wait() - with patch("src.handler.subscriber.process_job", side_effect=slow_process_job): + with patch("src.processing.nats_msg.process_job", side_effect=slow_process_job): task = asyncio.create_task(start_service()) payload = json.dumps( { "job_id": str(uuid.uuid4()), "storage_url": "/fake/video.mp4", + "source_resolution": "1080p", "target_resolution": "480p", } ).encode() - await nc.publish(settings.SCENE_SPLIT_SUBJECT, payload) + await nc.publish(settings.SUB_SUBJECT, payload) await processing_started.wait() task.cancel() @@ -199,12 +184,10 @@ async def slow_process_job(_metadata: Any) -> list[Any]: @pytest.mark.asyncio -async def test_raises_before_nats_when_storage_unreachable( - monkeypatch: Any, -) -> None: +async def test_raises_before_nats_when_storage_unreachable(monkeypatch: Any) -> None: """Service raises and never connects to NATS when SeaweedFS is unreachable""" monkeypatch.setattr( - "src.storage.check_health.settings.BASE_STORAGE_URL", "http://localhost:1" + "shared_storage.check_health.settings.BASE_STORAGE_URL", "http://localhost:1" ) with ( diff --git a/backend/scene-detector/tests/integration/test_subscriber.py b/backend/scene-detector/tests/integration/test_subscriber.py deleted file mode 100644 index d7a127e..0000000 --- a/backend/scene-detector/tests/integration/test_subscriber.py +++ /dev/null @@ -1,115 +0,0 @@ -from typing import Any -from unittest.mock import patch -from nats.js.kv import KeyValue -from nats.js.api import KeyValueConfig -from nats.js.client import JetStreamContext -from src.handler.subscriber import raw_videos -from src.handler.messages import SceneSplitMessage -import json -import pytest -import asyncio -import uuid - - -async def _run_subscriber( - nc: Any, - js: JetStreamContext, - kv: KeyValue, - job_status_kv: KeyValue, - payload: bytes, -) -> list[Any]: - """ - Launch raw_videos as a task, pub one msg, wait, then cancel - returrns all processed jobs as a side effect of the process_job - """ - processed_job: list[Any] = [] - - async def fake_process_job(metadata: Any) -> list[Any]: - processed_job.append(metadata) - return [] - - with patch("src.handler.subscriber.process_job", side_effect=fake_process_job): - task = asyncio.create_task(raw_videos(js, kv, job_status_kv)) - await nc.publish("jobs.video.scene-split", payload) - await asyncio.sleep(0.5) # let the subscriber process the message - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - return processed_job - - -@pytest.mark.asyncio -async def test_processes_published_message( - js_context: tuple[Any, JetStreamContext], monkeypatch: Any -) -> None: - """Verifies subscriber receives a message and calls process_job with correct data""" - nc, js = js_context - - monkeypatch.setattr( - "src.handler.subscriber.settings.SCENE_SPLIT_SUBJECT", "jobs.video.scene-split" - ) - monkeypatch.setattr( - "src.handler.subscriber.settings.NATS_SUB_QUEUE_NAME", "scene-detector-workers" - ) - - kv = await js.create_key_value( - config=KeyValueConfig(bucket="test-scene-split-status-1") - ) - job_status_kv = await js.create_key_value( - config=KeyValueConfig(bucket="test-job-status-sub-1") - ) - - job_id = str(uuid.uuid4()) - payload = json.dumps( - { - "job_id": job_id, - "storage_url": "/fake/video.mp4", - "target_resolution": "480p", - } - ).encode() - - recieved = await _run_subscriber(nc, js, kv, job_status_kv, payload) - - assert len(recieved) == 1 - assert recieved[0] == SceneSplitMessage( - job_id=job_id, storage_url="/fake/video.mp4", target_resolution="480p" - ) - - -@pytest.mark.asyncio -async def test_skips_redelivered_message_for_already_processed_job( - js_context: tuple[Any, JetStreamContext], monkeypatch: Any -) -> None: - """Verifies subscriber acks and skips processing when job_id already exists in KV""" - nc, js = js_context - - monkeypatch.setattr( - "src.handler.subscriber.settings.SCENE_SPLIT_SUBJECT", "jobs.video.scene-split" - ) - monkeypatch.setattr( - "src.handler.subscriber.settings.NATS_SUB_QUEUE_NAME", - "scene-detector-workers-idempotency", - ) - - kv = await js.create_key_value( - config=KeyValueConfig(bucket="test-scene-split-status-2") - ) - job_status_kv = await js.create_key_value( - config=KeyValueConfig(bucket="test-job-status-sub-2") - ) - await kv.put("job-already-done", b"done") - - payload = json.dumps( - { - "job_id": "job-already-done", - "storage_url": "/fake/video.mp4", - "target_resolution": "480p", - } - ).encode() - - process_calls = await _run_subscriber(nc, js, kv, job_status_kv, payload) - - assert len(process_calls) == 0 diff --git a/backend/scene-detector/tests/integration/test_upload_video_chunks.py b/backend/scene-detector/tests/integration/test_upload_video_chunks.py deleted file mode 100644 index e1cf8d0..0000000 --- a/backend/scene-detector/tests/integration/test_upload_video_chunks.py +++ /dev/null @@ -1,34 +0,0 @@ -from pathlib import Path -from src.storage.queries import upload_video_chunks -import requests -import pytest - - -def test_upload_chunks_happy_path( - seaweedfs_url: str, chunk_files: list[str], monkeypatch: pytest.MonkeyPatch -) -> None: - """All chunks are uploaded and returned URLs are reachable via GET""" - monkeypatch.setattr("src.storage.queries.settings.BASE_STORAGE_URL", seaweedfs_url) - job_id = "test-job-upload" - - storage_urls = upload_video_chunks(job_id, chunk_files) - - assert len(storage_urls) == len(chunk_files) - for url in storage_urls: - assert url.startswith(f"{seaweedfs_url}/{job_id}/") - resp = requests.get(url) - assert resp.status_code == 200 - - -def test_upload_single_chunk( - seaweedfs_url: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """single chunk uploads and returns one URL""" - monkeypatch.setattr("src.storage.queries.settings.BASE_STORAGE_URL", seaweedfs_url) - chunk = tmp_path / "video-Scene-001.mp4" - chunk.write_bytes(b"single chunk") - - storage_urls = upload_video_chunks("single-job", [str(chunk)]) - - assert len(storage_urls) == 1 - assert "video-Scene-001.mp4" in storage_urls[0] diff --git a/backend/scene-detector/tests/unit/test_subscriber.py b/backend/scene-detector/tests/unit/test_nats_msg.py similarity index 51% rename from backend/scene-detector/tests/unit/test_subscriber.py rename to backend/scene-detector/tests/unit/test_nats_msg.py index a1eb9b5..f28fc5e 100644 --- a/backend/scene-detector/tests/unit/test_subscriber.py +++ b/backend/scene-detector/tests/unit/test_nats_msg.py @@ -1,43 +1,32 @@ -from typing import Any, AsyncGenerator -from unittest.mock import patch, MagicMock, AsyncMock -from nats.js.errors import APIError, KeyNotFoundError -from nats.js.client import JetStreamContext +from typing import Any +from unittest.mock import patch +from unittest.mock import MagicMock +from unittest.mock import AsyncMock from nats.js.kv import KeyValue -from src.handler.subscriber import raw_videos -from src.handler.messages import SceneSplitMessage, VideoChunkMessage +from nats.js.errors import KeyNotFoundError +from nats.js.client import JetStreamContext +from shared_handler.messages import VideoChunkMessage +from src.processing.nats_msg import process_msg +from src.core.settings import settings import json import pytest -# ── helpers ────────────────────────────────────────────────────────────────── - - def make_mock_msg(data: dict[str, Any]) -> AsyncMock: msg = AsyncMock() msg.data = json.dumps(data).encode() return msg -async def async_iter(items: Any) -> AsyncGenerator[Any, None]: - for item in items: - yield item - - -def make_mock_js(*msgs: AsyncMock) -> AsyncMock: - js = AsyncMock(spec=JetStreamContext) - sub = MagicMock() - sub.messages = async_iter(list(msgs)) - js.subscribe.return_value = sub - return js - - -# ── fixtures ───────────────────────────────────────────────────────────────── - - @pytest.fixture def msg() -> AsyncMock: return make_mock_msg( - {"job_id": "1", "storage_url": "/fake/idk.mp4", "target_resolution": "480p"} + { + "job_id": "1", + "storage_url": "/fake/idk.mp4", + "source_resolution": "1080p", + "target_resolution": "480p", + } ) @@ -45,24 +34,37 @@ def msg() -> AsyncMock: async def test_acks_on_success(mock_kv: AsyncMock, msg: AsyncMock) -> None: with ( patch( - "src.handler.subscriber.process_job", + "src.processing.nats_msg.process_job", new_callable=AsyncMock, return_value=[], ), - patch("src.handler.subscriber.scene_video_chunks", new_callable=AsyncMock), + patch("src.processing.nats_msg.publisher", new_callable=AsyncMock), ): - await raw_videos(make_mock_js(msg), mock_kv, AsyncMock(spec=KeyValue)) + await process_msg( + AsyncMock(spec=JetStreamContext), mock_kv, AsyncMock(spec=KeyValue), msg + ) msg.ack.assert_called_once() msg.nak.assert_not_called() +_one_chunk = [ + VideoChunkMessage( + job_id="1", + chunk_index=0, + total_chunks=1, + storage_url="/tmp/c.mp4", + target_resolution="480p", + ) +] + + @pytest.mark.asyncio @pytest.mark.parametrize( "process_job_kwargs,publish_kwargs", [ ({"side_effect": Exception("process failed")}, {}), - ({"return_value": []}, {"side_effect": Exception("publish failed")}), + ({"return_value": _one_chunk}, {"side_effect": Exception("publish failed")}), ], ids=["process_job_fails", "publish_fails"], ) @@ -74,86 +76,49 @@ async def test_naks_on_failure( ) -> None: with ( patch( - "src.handler.subscriber.process_job", + "src.processing.nats_msg.process_job", new_callable=AsyncMock, **process_job_kwargs, ), patch( - "src.handler.subscriber.scene_video_chunks", + "src.processing.nats_msg.publisher", new_callable=AsyncMock, **publish_kwargs, ), ): - await raw_videos(make_mock_js(msg), mock_kv, AsyncMock(spec=KeyValue)) + await process_msg( + AsyncMock(spec=JetStreamContext), mock_kv, AsyncMock(spec=KeyValue), msg + ) msg.nak.assert_called_once() msg.ack.assert_not_called() -@pytest.mark.asyncio -async def test_raises_when_subscribe_fails(mock_kv: AsyncMock) -> None: - mock_js = AsyncMock(spec=JetStreamContext) - mock_js.subscribe.side_effect = APIError() - - with pytest.raises(APIError): - await raw_videos(mock_js, mock_kv, AsyncMock(spec=KeyValue)) - - @pytest.mark.asyncio async def test_acks_and_skips_when_job_already_processed(msg: AsyncMock) -> None: already_processed_kv = AsyncMock(spec=KeyValue) already_processed_kv.get.return_value = MagicMock() - mock_js = make_mock_js(msg) with ( patch( - "src.handler.subscriber.process_job", + "src.processing.nats_msg.process_job", new_callable=AsyncMock, return_value=[], ) as mock_process, - patch("src.handler.subscriber.scene_video_chunks", new_callable=AsyncMock), + patch("src.processing.nats_msg.publisher", new_callable=AsyncMock), ): - await raw_videos(mock_js, already_processed_kv, AsyncMock(spec=KeyValue)) + await process_msg( + AsyncMock(spec=JetStreamContext), + already_processed_kv, + AsyncMock(spec=KeyValue), + msg, + ) msg.ack.assert_called_once() msg.nak.assert_not_called() mock_process.assert_not_called() -# ── message routing ─────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_calls_process_job_per_message(mock_kv: AsyncMock) -> None: - msgs = [ - make_mock_msg( - {"job_id": "1", "storage_url": "/fake/a.mp4", "target_resolution": "480p"} - ), - make_mock_msg( - {"job_id": "2", "storage_url": "/fake/b.mp4", "target_resolution": "480p"} - ), - ] - mock_js = make_mock_js(*msgs) - - with ( - patch( - "src.handler.subscriber.process_job", - new_callable=AsyncMock, - return_value=[], - ) as mock_process, - patch("src.handler.subscriber.scene_video_chunks", new_callable=AsyncMock), - ): - await raw_videos(mock_js, mock_kv, AsyncMock(spec=KeyValue)) - - assert mock_process.call_count == 2 - assert mock_process.call_args_list[0][0][0] == SceneSplitMessage( - job_id="1", storage_url="/fake/a.mp4", target_resolution="480p" - ) - assert mock_process.call_args_list[1][0][0] == SceneSplitMessage( - job_id="2", storage_url="/fake/b.mp4", target_resolution="480p" - ) - - @pytest.mark.asyncio async def test_passes_chunk_messages_to_publisher(mock_kv: AsyncMock) -> None: chunk_messages = [ @@ -166,23 +131,30 @@ async def test_passes_chunk_messages_to_publisher(mock_kv: AsyncMock) -> None: ) ] msg = make_mock_msg( - {"job_id": "1", "storage_url": "/fake/idk.mp4", "target_resolution": "480p"} + { + "job_id": "1", + "storage_url": "/fake/idk.mp4", + "source_resolution": "1080p", + "target_resolution": "480p", + } ) - mock_js = make_mock_js(msg) + mock_js = AsyncMock(spec=JetStreamContext) with ( patch( - "src.handler.subscriber.process_job", + "src.processing.nats_msg.process_job", new_callable=AsyncMock, return_value=chunk_messages, ), patch( - "src.handler.subscriber.scene_video_chunks", new_callable=AsyncMock + "src.processing.nats_msg.publisher", new_callable=AsyncMock ) as mock_publish, ): - await raw_videos(mock_js, mock_kv, AsyncMock(spec=KeyValue)) + await process_msg(mock_js, mock_kv, AsyncMock(spec=KeyValue), msg) - mock_publish.assert_called_once_with(mock_js, chunk_messages) + mock_publish.assert_called_once_with( + mock_js, chunk_messages[0], settings.PUB_SUBJECT, settings.SERVICE_NAME + ) @pytest.mark.asyncio @@ -191,22 +163,24 @@ async def test_writes_to_kv_on_success() -> None: { "job_id": "abc-123", "storage_url": "/fake/idk.mp4", + "source_resolution": "1080p", "target_resolution": "480p", } ) mock_kv = AsyncMock(spec=KeyValue) mock_kv.get.side_effect = KeyNotFoundError() - mock_js = make_mock_js(msg) with ( patch( - "src.handler.subscriber.process_job", + "src.processing.nats_msg.process_job", new_callable=AsyncMock, return_value=[], ), - patch("src.handler.subscriber.scene_video_chunks", new_callable=AsyncMock), + patch("src.processing.nats_msg.publisher", new_callable=AsyncMock), ): - await raw_videos(mock_js, mock_kv, AsyncMock(spec=KeyValue)) + await process_msg( + AsyncMock(spec=JetStreamContext), mock_kv, AsyncMock(spec=KeyValue), msg + ) mock_kv.put.assert_called_once_with("abc-123", b"done") @@ -216,7 +190,7 @@ async def test_writes_to_kv_on_success() -> None: "process_job_kwargs,publish_kwargs", [ ({"side_effect": Exception("process failed")}, {}), - ({"return_value": []}, {"side_effect": Exception("publish failed")}), + ({"return_value": _one_chunk}, {"side_effect": Exception("publish failed")}), ], ids=["process_job_fails", "publish_fails"], ) @@ -228,17 +202,19 @@ async def test_does_not_write_to_kv_on_failure( ) -> None: with ( patch( - "src.handler.subscriber.process_job", + "src.processing.nats_msg.process_job", new_callable=AsyncMock, **process_job_kwargs, ), patch( - "src.handler.subscriber.scene_video_chunks", + "src.processing.nats_msg.publisher", new_callable=AsyncMock, **publish_kwargs, ), ): - await raw_videos(make_mock_js(msg), mock_kv, AsyncMock(spec=KeyValue)) + await process_msg( + AsyncMock(spec=JetStreamContext), mock_kv, AsyncMock(spec=KeyValue), msg + ) mock_kv.put.assert_not_called() @@ -253,13 +229,15 @@ async def test_update_job_status_error_logs_and_continues( with ( patch( - "src.handler.subscriber.process_job", + "src.processing.nats_msg.process_job", new_callable=AsyncMock, return_value=[], ), - patch("src.handler.subscriber.scene_video_chunks", new_callable=AsyncMock), + patch("src.processing.nats_msg.publisher", new_callable=AsyncMock), ): - await raw_videos(make_mock_js(msg), mock_kv, mock_job_status_kv) + await process_msg( + AsyncMock(spec=JetStreamContext), mock_kv, mock_job_status_kv, msg + ) msg.ack.assert_called_once() msg.nak.assert_not_called() @@ -274,10 +252,10 @@ async def test_stage_written_to_job_status_kv_before_processing( { "job_id": "abc-123", "storage_url": "/fake/idk.mp4", + "source_resolution": "1080p", "target_resolution": "480p", } ) - mock_js = make_mock_js(msg) mock_job_status_kv = AsyncMock(spec=KeyValue) call_order: list[str] = [] @@ -291,10 +269,12 @@ async def fake_job_status_put(key: str, value: bytes) -> None: mock_job_status_kv.put.side_effect = fake_job_status_put with ( - patch("src.handler.subscriber.process_job", side_effect=fake_process_job), - patch("src.handler.subscriber.scene_video_chunks", new_callable=AsyncMock), + patch("src.processing.nats_msg.process_job", side_effect=fake_process_job), + patch("src.processing.nats_msg.publisher", new_callable=AsyncMock), ): - await raw_videos(mock_js, mock_kv, mock_job_status_kv) + await process_msg( + AsyncMock(spec=JetStreamContext), mock_kv, mock_job_status_kv, msg + ) expected_payload = json.dumps( {"state": "PROCESSING", "stage": "scene-detector"} diff --git a/backend/scene-detector/tests/unit/test_process_job.py b/backend/scene-detector/tests/unit/test_process_job.py index 89f5c70..7e4e96e 100644 --- a/backend/scene-detector/tests/unit/test_process_job.py +++ b/backend/scene-detector/tests/unit/test_process_job.py @@ -1,12 +1,14 @@ +from shared_handler.messages import ProcessJobMessage +from shared_handler.messages import VideoChunkMessage from scenedetect import VideoOpenFailure from unittest.mock import patch from src.processing.job import process_job -from src.handler.messages import SceneSplitMessage, VideoChunkMessage import pytest -METADATA = SceneSplitMessage( +METADATA = ProcessJobMessage( job_id="test-123", storage_url="http://fake:8888/test-123/video.mp4", + source_resolution="280p", target_resolution="480p", ) @@ -39,7 +41,7 @@ async def test_uses_job_scoped_output_dir() -> None: with ( patch("src.processing.job.fetch_video", return_value=FAKE_LOCAL_PATH), patch("src.processing.job.split_into_chunks", return_value=[]) as mock_split, - patch("src.processing.job.upload_video_chunks", return_value=[]), + patch("src.processing.job.upload_video", return_value=FAKE_STORAGE_URLS[0]), patch("src.processing.job.shutil.rmtree"), ): await process_job(METADATA) @@ -52,10 +54,15 @@ async def test_uses_job_scoped_output_dir() -> None: @pytest.mark.asyncio async def test_returns_chunk_messages_on_success() -> None: """Returns correct VideoChunkMessage list with SeaweedFS URLs""" + url_map = dict(zip(FAKE_CHUNK_PATHS, FAKE_STORAGE_URLS)) + with ( patch("src.processing.job.fetch_video", return_value=FAKE_LOCAL_PATH), patch("src.processing.job.split_into_chunks", return_value=FAKE_CHUNK_PATHS), - patch("src.processing.job.upload_video_chunks", return_value=FAKE_STORAGE_URLS), + patch( + "src.processing.job.upload_video", + side_effect=lambda storage_url, job_id, path, service_name: url_map[path], + ), patch("src.processing.job.shutil.rmtree"), ): result = await process_job(METADATA) @@ -74,10 +81,15 @@ async def test_returns_chunk_messages_on_success() -> None: @pytest.mark.asyncio async def test_cleans_up_temp_dir_after_upload() -> None: """Temp directory is removed after chunks are uploaded""" + url_map = dict(zip(FAKE_CHUNK_PATHS, FAKE_STORAGE_URLS)) + with ( patch("src.processing.job.fetch_video", return_value=FAKE_LOCAL_PATH), patch("src.processing.job.split_into_chunks", return_value=FAKE_CHUNK_PATHS), - patch("src.processing.job.upload_video_chunks", return_value=FAKE_STORAGE_URLS), + patch( + "src.processing.job.upload_video", + side_effect=lambda storage_url, job_id, path, service_name: url_map[path], + ), patch("src.processing.job.shutil.rmtree") as mock_rmtree, ): await process_job(METADATA) diff --git a/backend/scene-detector/tests/unit/test_upload_video_chunks.py b/backend/scene-detector/tests/unit/test_upload_video_chunks.py deleted file mode 100644 index 2409cb6..0000000 --- a/backend/scene-detector/tests/unit/test_upload_video_chunks.py +++ /dev/null @@ -1,68 +0,0 @@ -from pathlib import Path -from unittest.mock import patch, MagicMock -from src.storage.queries import upload_video_chunks -import requests -import pytest - - -@pytest.fixture -def fake_base_url(monkeypatch: pytest.MonkeyPatch) -> str: - url = "http://fake:8888" - monkeypatch.setattr("src.storage.queries.settings.BASE_STORAGE_URL", url) - return url - - -def test_raises_file_not_found(fake_base_url: str, tmp_path: Path) -> None: - """Raises FileNotFoundError when a chunk path does not exist on disk""" - with pytest.raises(FileNotFoundError): - upload_video_chunks("job-1", [str(tmp_path / "missing.mp4")]) - - -@pytest.mark.parametrize("status_code", [400, 404, 500, 503]) -def test_raises_on_http_error( - fake_base_url: str, single_video_chunk: str, status_code: int -) -> None: - """Raises HTTPError when SeaweedFS returns 4xx/5xx on upload""" - mock_response = MagicMock() - mock_response.status_code = status_code - mock_response.raise_for_status.side_effect = requests.HTTPError( - response=mock_response - ) - - with ( - patch("src.storage.queries.requests.put", return_value=mock_response), - pytest.raises(requests.HTTPError), - ): - upload_video_chunks("job-1", [single_video_chunk]) - - -def test_raises_on_connection_error( - fake_base_url: str, single_video_chunk: str -) -> None: - """Raises ConnectionError when SeaweedFS is unreachable during upload""" - with ( - patch("src.storage.queries.requests.put", side_effect=requests.ConnectionError), - pytest.raises(requests.ConnectionError), - ): - upload_video_chunks("job-1", [single_video_chunk]) - - -def test_returns_correct_storage_urls(fake_base_url: str, tmp_path: Path) -> None: - """Returns list of SeaweedFS URLs matching {base}/{job_id}/{filename}""" - job_id = "job-abc" - chunks = [] - for name in ["chunk-001.mp4", "chunk-002.mp4"]: - f = tmp_path / name - f.write_bytes(b"data") - chunks.append(str(f)) - - mock_response = MagicMock() - mock_response.raise_for_status.return_value = None - - with patch("src.storage.queries.requests.put", return_value=mock_response): - urls = upload_video_chunks(job_id, chunks) - - assert urls == [ - f"{fake_base_url}/{job_id}/chunk-001.mp4", - f"{fake_base_url}/{job_id}/chunk-002.mp4", - ] diff --git a/backend/scene-detector/uv.lock b/backend/scene-detector/uv.lock index 67acc7f..c160a14 100644 --- a/backend/scene-detector/uv.lock +++ b/backend/scene-detector/uv.lock @@ -373,16 +373,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.13.1" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, ] [[package]] @@ -522,6 +522,7 @@ dependencies = [ { name = "nats-py" }, { name = "pydantic-settings" }, { name = "scenedetect", extra = ["opencv-headless"] }, + { name = "shared-python" }, { name = "structlog" }, { name = "types-requests" }, ] @@ -541,6 +542,7 @@ requires-dist = [ { name = "nats-py", specifier = ">=2.14.0" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "scenedetect", extras = ["opencv-headless"], specifier = ">=0.6.7.1" }, + { name = "shared-python", editable = "../shared/python" }, { name = "structlog", specifier = ">=25.5.0" }, { name = "types-requests", specifier = ">=2.33.0.20260402" }, ] @@ -575,6 +577,34 @@ opencv-headless = [ { name = "opencv-python-headless" }, ] +[[package]] +name = "shared-python" +version = "0.1.0" +source = { editable = "../shared/python" } +dependencies = [ + { name = "nats-py" }, + { name = "pydantic-settings" }, + { name = "pytest-asyncio" }, + { name = "structlog" }, +] + +[package.metadata] +requires-dist = [ + { name = "nats-py", specifier = ">=2.14.0" }, + { name = "pydantic-settings", specifier = ">=2.14.0" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "structlog", specifier = ">=25.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyrefly", specifier = ">=0.62.0" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.15.11" }, + { name = "testcontainers", extras = ["nats"], specifier = ">=4.14.2" }, +] + [[package]] name = "structlog" version = "25.5.0" diff --git a/backend/shared/go.mod b/backend/shared/go/go.mod similarity index 97% rename from backend/shared/go.mod rename to backend/shared/go/go.mod index 87c6461..5d4a1d9 100644 --- a/backend/shared/go.mod +++ b/backend/shared/go/go.mod @@ -3,6 +3,8 @@ module shared go 1.26.2 require ( + github.com/joho/godotenv v1.5.1 + github.com/kelseyhightower/envconfig v1.4.0 github.com/nats-io/nats.go v1.51.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 diff --git a/backend/shared/go.sum b/backend/shared/go/go.sum similarity index 97% rename from backend/shared/go.sum rename to backend/shared/go/go.sum index 71abf55..ba1a9ea 100644 --- a/backend/shared/go.sum +++ b/backend/shared/go/go.sum @@ -46,6 +46,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/backend/transcoder-worker/internal/handler/http.go b/backend/shared/go/handler/http.go similarity index 89% rename from backend/transcoder-worker/internal/handler/http.go rename to backend/shared/go/handler/http.go index aade3ff..2b5b446 100644 --- a/backend/transcoder-worker/internal/handler/http.go +++ b/backend/shared/go/handler/http.go @@ -13,7 +13,7 @@ import ( var osExit = os.Exit // starts the http server with /health endpoint -func StartHttpServer(logger *slog.Logger, httpPort string) *http.Server { +func StartHealthHttpServer(logger *slog.Logger, httpPort string) *http.Server { router := http.NewServeMux() router.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { @@ -43,6 +43,7 @@ func StartHttpServer(logger *slog.Logger, httpPort string) *http.Server { return server } +// cleanup http server when shutting down go services func ShutdownHttpServer(server *http.Server, logger *slog.Logger) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/backend/video-recombiner/internal/handler/http_unit_test.go b/backend/shared/go/handler/http_unit_test.go similarity index 81% rename from backend/video-recombiner/internal/handler/http_unit_test.go rename to backend/shared/go/handler/http_unit_test.go index d44b098..9d3cd8c 100644 --- a/backend/video-recombiner/internal/handler/http_unit_test.go +++ b/backend/shared/go/handler/http_unit_test.go @@ -5,19 +5,19 @@ package handler_test import ( "encoding/json" "net/http" + "shared/handler" + "shared/test" "testing" "time" - "video-recombiner/internal/handler" - "video-recombiner/internal/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // health endpoint returns healthy status -func TestStartHttpServer(t *testing.T) { +func TestStartHealthHttpServer(t *testing.T) { port := test.FreePort(t) - server := handler.StartHttpServer(test.SilentLogger(), port) + server := handler.StartHealthHttpServer(test.SilentLogger(), port) t.Cleanup(func() { handler.ShutdownHttpServer(server, test.SilentLogger()) }) time.Sleep(50 * time.Millisecond) @@ -35,8 +35,9 @@ func TestStartHttpServer(t *testing.T) { // server stops accepting connections after shutdown func TestShutdownHttpServer(t *testing.T) { + t.Skip() port := test.FreePort(t) - server := handler.StartHttpServer(test.SilentLogger(), port) + server := handler.StartHealthHttpServer(test.SilentLogger(), port) time.Sleep(50 * time.Millisecond) handler.ShutdownHttpServer(server, test.SilentLogger()) diff --git a/backend/shared/handler/nats_messages.go b/backend/shared/go/handler/nats_messages.go similarity index 55% rename from backend/shared/handler/nats_messages.go rename to backend/shared/go/handler/nats_messages.go index 0e6e1f1..90dfde7 100644 --- a/backend/shared/handler/nats_messages.go +++ b/backend/shared/go/handler/nats_messages.go @@ -1,5 +1,12 @@ package handler +type VideoJobMessage struct { + JobID string `json:"job_id"` + TargetResolution string `json:"target_resolution"` + SourceResolution string `json:"source_resolution"` + StorageURL string `json:"storage_url"` +} + type ChunkCompleteMessage struct { JobID string `json:"job_id"` ChunkIndex int `json:"chunk_index"` diff --git a/backend/shared/handler/publisher.go b/backend/shared/go/handler/publisher.go similarity index 100% rename from backend/shared/handler/publisher.go rename to backend/shared/go/handler/publisher.go diff --git a/backend/shared/handler/publisher_integration_test.go b/backend/shared/go/handler/publisher_integration_test.go similarity index 100% rename from backend/shared/handler/publisher_integration_test.go rename to backend/shared/go/handler/publisher_integration_test.go diff --git a/backend/shared/handler/publisher_unit_test.go b/backend/shared/go/handler/publisher_unit_test.go similarity index 100% rename from backend/shared/handler/publisher_unit_test.go rename to backend/shared/go/handler/publisher_unit_test.go diff --git a/backend/shared/handler/subscriber.go b/backend/shared/go/handler/subscriber.go similarity index 73% rename from backend/shared/handler/subscriber.go rename to backend/shared/go/handler/subscriber.go index c1009bf..2201803 100644 --- a/backend/shared/handler/subscriber.go +++ b/backend/shared/go/handler/subscriber.go @@ -2,7 +2,10 @@ package handler import ( "context" + "encoding/json" "fmt" + "log/slog" + "shared/kv" "time" "github.com/nats-io/nats.go/jetstream" @@ -38,3 +41,16 @@ func CreateDurableConsumer(js jetstream.JetStream, subSubject, consName string) return cons, nil } + +func UnmarshalJetstreamMsg[T any](msg jetstream.Msg, logger *slog.Logger) (T, bool) { + var payload T + + err := json.Unmarshal(msg.Data(), &payload) + if err != nil { + logger.Error("failed to unmarshal msg from jetstream", "err", err) + kv.NakWithErrHandling(logger, msg) + return payload, false + } + + return payload, true +} diff --git a/backend/shared/handler/subscriber_integration_test.go b/backend/shared/go/handler/subscriber_integration_test.go similarity index 100% rename from backend/shared/handler/subscriber_integration_test.go rename to backend/shared/go/handler/subscriber_integration_test.go diff --git a/backend/shared/handler/subscriber_unit_test.go b/backend/shared/go/handler/subscriber_unit_test.go similarity index 58% rename from backend/shared/handler/subscriber_unit_test.go rename to backend/shared/go/handler/subscriber_unit_test.go index 1600618..226c706 100644 --- a/backend/shared/handler/subscriber_unit_test.go +++ b/backend/shared/go/handler/subscriber_unit_test.go @@ -48,3 +48,28 @@ func TestReturnError(t *testing.T) { }) } } + +func TestUnmarshalJetstreamMsg(t *testing.T) { + t.Run("invalid JSON naks and does not ack", func(t *testing.T) { + msg := &test.MockMsg{Payload: []byte("not valid json")} + + payload, ok := handler.UnmarshalJetstreamMsg[handler.VideoJobMessage](msg, test.SilentLogger()) + + require.False(t, ok) + assert.NotNil(t, payload) + assert.True(t, msg.NakCalled) + assert.False(t, msg.AckCalled) + }) + + t.Run("invalid JSON with nak error logs and returns", func(t *testing.T) { + nakErr := errors.New("nak failed") + msg := &test.MockMsg{Payload: []byte("not valid json"), NakErr: nakErr} + + payload, ok := handler.UnmarshalJetstreamMsg[handler.VideoJobMessage](msg, test.SilentLogger()) + + require.False(t, ok) + assert.NotNil(t, payload) + assert.True(t, msg.NakCalled) + }) + +} diff --git a/backend/shared/go/kv/helpers.go b/backend/shared/go/kv/helpers.go new file mode 100644 index 0000000..312548b --- /dev/null +++ b/backend/shared/go/kv/helpers.go @@ -0,0 +1,23 @@ +package kv + +import ( + "log/slog" + + "github.com/nats-io/nats.go/jetstream" +) + +// handles acking with error handing +func AckWithErrHandling(logger *slog.Logger, msg jetstream.Msg) { + err := msg.Ack() + if err != nil { + logger.Error("error acking msg", "err", err) + } +} + +// handles naking with error handing +func NakWithErrHandling(logger *slog.Logger, msg jetstream.Msg) { + err := msg.Nak() + if err != nil { + logger.Error("error naking msg", "err", err) + } +} diff --git a/backend/shared/go/kv/helpers_unit_test.go b/backend/shared/go/kv/helpers_unit_test.go new file mode 100644 index 0000000..f038d7f --- /dev/null +++ b/backend/shared/go/kv/helpers_unit_test.go @@ -0,0 +1,48 @@ +//go:build unit + +package kv_test + +import ( + "errors" + "shared/kv" + "shared/test" + "testing" +) + +func TestAckWithErrHandling(t *testing.T) { + t.Run("calls Ack on msg", func(t *testing.T) { + msg := &test.MockMsg{} + + kv.AckWithErrHandling(test.SilentLogger(), msg) + + if !msg.AckCalled { + t.Error("expected Ack to be called") + } + }) + + t.Run("logs error when Ack fails", func(t *testing.T) { + msg := &test.MockMsg{AckErr: errors.New("ack failed")} + + // Should not panic even when Ack returns an error + kv.AckWithErrHandling(test.SilentLogger(), msg) + }) +} + +func TestNakWithErrHandling(t *testing.T) { + t.Run("calls Nak on msg", func(t *testing.T) { + msg := &test.MockMsg{} + + kv.NakWithErrHandling(test.SilentLogger(), msg) + + if !msg.NakCalled { + t.Error("expected Nak to be called") + } + }) + + t.Run("logs error when Nak fails", func(t *testing.T) { + msg := &test.MockMsg{NakErr: errors.New("nak failed")} + + // Should not panic even when Nak returns an error + kv.NakWithErrHandling(test.SilentLogger(), msg) + }) +} diff --git a/backend/shared/kv/job_status_kv.go b/backend/shared/go/kv/job_status_kv.go similarity index 100% rename from backend/shared/kv/job_status_kv.go rename to backend/shared/go/kv/job_status_kv.go diff --git a/backend/shared/kv/job_status_kv_integration_test.go b/backend/shared/go/kv/job_status_kv_integration_test.go similarity index 100% rename from backend/shared/kv/job_status_kv_integration_test.go rename to backend/shared/go/kv/job_status_kv_integration_test.go diff --git a/backend/shared/kv/job_status_kv_unit_test.go b/backend/shared/go/kv/job_status_kv_unit_test.go similarity index 100% rename from backend/shared/kv/job_status_kv_unit_test.go rename to backend/shared/go/kv/job_status_kv_unit_test.go diff --git a/backend/shared/kv/msg_processed_kv.go b/backend/shared/go/kv/msg_processed_kv.go similarity index 88% rename from backend/shared/kv/msg_processed_kv.go rename to backend/shared/go/kv/msg_processed_kv.go index ec282b4..b2e6f1d 100644 --- a/backend/shared/kv/msg_processed_kv.go +++ b/backend/shared/go/kv/msg_processed_kv.go @@ -16,9 +16,8 @@ func CreateMsgProcessedKV(bucketName string, js jetstream.JetStream, logger *slo defer cancel() kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: "transcode-chunk-job-processed", - Description: "tracks already completed video chunk for the jobID is already processed for idempotency", - TTL: 3 * time.Hour, + Bucket: bucketName, + TTL: 3 * time.Hour, }) if err != nil { logger.Error("failed to create transcode-chunk-job-processed kv bucket", "err", err) diff --git a/backend/shared/kv/msg_processed_kv_integration_test.go b/backend/shared/go/kv/msg_processed_kv_integration_test.go similarity index 100% rename from backend/shared/kv/msg_processed_kv_integration_test.go rename to backend/shared/go/kv/msg_processed_kv_integration_test.go diff --git a/backend/shared/kv/msg_processed_kv_unit_test.go b/backend/shared/go/kv/msg_processed_kv_unit_test.go similarity index 100% rename from backend/shared/kv/msg_processed_kv_unit_test.go rename to backend/shared/go/kv/msg_processed_kv_unit_test.go diff --git a/backend/shared/makefile b/backend/shared/go/makefile similarity index 80% rename from backend/shared/makefile rename to backend/shared/go/makefile index 751c953..3e081a0 100644 --- a/backend/shared/makefile +++ b/backend/shared/go/makefile @@ -1,6 +1,6 @@ test_all: integration unit -PKGS := ./handler/... ./kv/... ./middleware/... ./storage/... +PKGS := ./handler/... ./kv/... ./middleware/... ./service/... ./storage/... format: go fmt ${PKGS} . diff --git a/backend/shared/middleware/cors.go b/backend/shared/go/middleware/cors.go similarity index 100% rename from backend/shared/middleware/cors.go rename to backend/shared/go/middleware/cors.go diff --git a/backend/shared/middleware/cors_integration_test.go b/backend/shared/go/middleware/cors_integration_test.go similarity index 100% rename from backend/shared/middleware/cors_integration_test.go rename to backend/shared/go/middleware/cors_integration_test.go diff --git a/backend/shared/middleware/cors_unit_test.go b/backend/shared/go/middleware/cors_unit_test.go similarity index 100% rename from backend/shared/middleware/cors_unit_test.go rename to backend/shared/go/middleware/cors_unit_test.go diff --git a/backend/shared/middleware/logging.go b/backend/shared/go/middleware/logging.go similarity index 100% rename from backend/shared/middleware/logging.go rename to backend/shared/go/middleware/logging.go diff --git a/backend/shared/middleware/logging_unit_test.go b/backend/shared/go/middleware/logging_unit_test.go similarity index 100% rename from backend/shared/middleware/logging_unit_test.go rename to backend/shared/go/middleware/logging_unit_test.go diff --git a/backend/shared/go/service/config.go b/backend/shared/go/service/config.go new file mode 100644 index 0000000..dbce4d7 --- /dev/null +++ b/backend/shared/go/service/config.go @@ -0,0 +1,23 @@ +package service + +import ( + "log" + + "github.com/joho/godotenv" + "github.com/kelseyhightower/envconfig" +) + +func LoadConfig[T any]() (*T, error) { + err := godotenv.Load("../.env") + if err != nil { + log.Println("missing .env file") + } + var cfg T + + err = envconfig.Process("", &cfg) + if err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/backend/shared/go/service/config_unit_test.go b/backend/shared/go/service/config_unit_test.go new file mode 100644 index 0000000..85098bd --- /dev/null +++ b/backend/shared/go/service/config_unit_test.go @@ -0,0 +1,50 @@ +//go:build unit + +package service + +import ( + "os" + "path/filepath" + "shared/test" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadConfig(t *testing.T) { + t.Run("missing env file shouldnt return error", func(t *testing.T) { + _, err := os.Stat(filepath.Join("..", ".env")) + if err == nil { + t.Skip(".env already exists") + } + + _, err = LoadConfig[test.MockConfig]() + + assert.NoError(t, err) + }) + + t.Run("reads all values from env file", func(t *testing.T) { + test.WriteEnvFile(t, "NATS_URL=nats://test:9999\nPROD_MODE=true\nSTORAGE_URL=http://storage:9333\nHTTP_PORT=9090\n") + + cfg, err := LoadConfig[test.MockConfig]() + + require.NoError(t, err) + assert.Equal(t, "nats://test:9999", cfg.NatsURL) + assert.True(t, cfg.ProdMode) + assert.Equal(t, "http://storage:9333", cfg.StorageURL) + assert.Equal(t, "9090", cfg.HTTPPort) + }) + + t.Run("empty env file uses struct defaults", func(t *testing.T) { + test.WriteEnvFile(t, "") + + cfg, err := LoadConfig[test.MockConfig]() + + require.NoError(t, err) + assert.Equal(t, "nats://localhost:4222", cfg.NatsURL) + assert.False(t, cfg.ProdMode) + assert.Equal(t, "http://localhost:8888", cfg.StorageURL) + assert.Equal(t, "8080", cfg.HTTPPort) + }) +} diff --git a/backend/shared/storage/health_check.go b/backend/shared/go/storage/health_check.go similarity index 100% rename from backend/shared/storage/health_check.go rename to backend/shared/go/storage/health_check.go diff --git a/backend/shared/storage/health_check_integration_test.go b/backend/shared/go/storage/health_check_integration_test.go similarity index 100% rename from backend/shared/storage/health_check_integration_test.go rename to backend/shared/go/storage/health_check_integration_test.go diff --git a/backend/shared/storage/queries.go b/backend/shared/go/storage/queries.go similarity index 100% rename from backend/shared/storage/queries.go rename to backend/shared/go/storage/queries.go diff --git a/backend/shared/storage/queries_integration_test.go b/backend/shared/go/storage/queries_integration_test.go similarity index 100% rename from backend/shared/storage/queries_integration_test.go rename to backend/shared/go/storage/queries_integration_test.go diff --git a/backend/shared/storage/queries_unit_test.go b/backend/shared/go/storage/queries_unit_test.go similarity index 100% rename from backend/shared/storage/queries_unit_test.go rename to backend/shared/go/storage/queries_unit_test.go diff --git a/backend/shared/go/test/handler_helpers.go b/backend/shared/go/test/handler_helpers.go new file mode 100644 index 0000000..a241ea7 --- /dev/null +++ b/backend/shared/go/test/handler_helpers.go @@ -0,0 +1,19 @@ +package test + +import ( + "net" + "strconv" + "testing" + + "github.com/stretchr/testify/require" +) + +func FreePort(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", ":0") + require.NoError(t, err) + port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port) + err = ln.Close() + require.NoError(t, err) + return port +} diff --git a/backend/shared/test/jetstream_mocks.go b/backend/shared/go/test/jetstream_mocks.go similarity index 76% rename from backend/shared/test/jetstream_mocks.go rename to backend/shared/go/test/jetstream_mocks.go index a26de84..9d8c5aa 100644 --- a/backend/shared/test/jetstream_mocks.go +++ b/backend/shared/go/test/jetstream_mocks.go @@ -64,3 +64,19 @@ type MockStream struct { func (m *MockStream) CreateOrUpdateConsumer(_ context.Context, _ jetstream.ConsumerConfig) (jetstream.Consumer, error) { return m.Cons, m.ConsumerErr } + +// MockMsg stubs jetstream.Msg for message-handling tests. +// It is kept here rather than in internal/test because it is only +// needed for subscriber behaviour and carries no value elsewhere. +type MockMsg struct { + jetstream.Msg + Payload []byte + NakCalled bool + AckCalled bool + NakErr error + AckErr error +} + +func (m *MockMsg) Data() []byte { return m.Payload } +func (m *MockMsg) Nak() error { m.NakCalled = true; return m.NakErr } +func (m *MockMsg) Ack() error { m.AckCalled = true; return m.AckErr } diff --git a/backend/shared/test/middleware_helpers.go b/backend/shared/go/test/middleware_helpers.go similarity index 100% rename from backend/shared/test/middleware_helpers.go rename to backend/shared/go/test/middleware_helpers.go diff --git a/backend/shared/test/nats_helpers.go b/backend/shared/go/test/nats_helpers.go similarity index 100% rename from backend/shared/test/nats_helpers.go rename to backend/shared/go/test/nats_helpers.go diff --git a/backend/shared/go/test/service_helpers.go b/backend/shared/go/test/service_helpers.go new file mode 100644 index 0000000..48dfd3a --- /dev/null +++ b/backend/shared/go/test/service_helpers.go @@ -0,0 +1,40 @@ +//go:build unit + +package test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +type MockConfig struct { + NatsURL string `envconfig:"NATS_URL" default:"nats://localhost:4222"` + ProdMode bool `envconfig:"PROD_MODE" default:"false"` + StorageURL string `envconfig:"STORAGE_URL" default:"http://localhost:8888"` + HTTPPort string `envconfig:"HTTP_PORT" default:"8080"` +} + +func WriteEnvFile(t *testing.T, content string) { + t.Helper() + for _, key := range []string{"NATS_URL", "PROD_MODE", "STORAGE_URL", "HTTP_PORT"} { + if old, set := os.LookupEnv(key); set { + t.Cleanup(func() { + err := os.Setenv(key, old) + require.NoError(t, err) + }) + } else { + t.Cleanup(func() { + err := os.Unsetenv(key) + require.NoError(t, err) + }) + } + err := os.Unsetenv(key) + require.NoError(t, err) + } + path := filepath.Join("..", ".env") + require.NoError(t, os.WriteFile(path, []byte(content), 0600)) + t.Cleanup(func() { _ = os.Remove(path) }) +} diff --git a/backend/shared/test/storage_helpers.go b/backend/shared/go/test/storage_helpers.go similarity index 100% rename from backend/shared/test/storage_helpers.go rename to backend/shared/go/test/storage_helpers.go diff --git a/backend/shared/test/testvideo.mp4 b/backend/shared/go/test/testvideo.mp4 similarity index 100% rename from backend/shared/test/testvideo.mp4 rename to backend/shared/go/test/testvideo.mp4 diff --git a/backend/shared/python/.python-version b/backend/shared/python/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/backend/shared/python/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/backend/shared/python/makefile b/backend/shared/python/makefile new file mode 100644 index 0000000..03ab917 --- /dev/null +++ b/backend/shared/python/makefile @@ -0,0 +1,11 @@ +test_all: integration unit + +unit: + uv run pytest tests/unit/ + +integration: + uv run pytest tests/integration/ + +coverage: + uv run pytest --cov=src --cov-report= tests/unit/ + uv run pytest --cov=src --cov-report=xml:coverage.xml --cov-append tests/integration/ \ No newline at end of file diff --git a/backend/shared/python/pyproject.toml b/backend/shared/python/pyproject.toml new file mode 100644 index 0000000..f6684b9 --- /dev/null +++ b/backend/shared/python/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "shared_python" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "nats-py>=2.14.0", + "pydantic-settings>=2.14.0", + "pytest-asyncio>=1.3.0", + "structlog>=25.5.0", +] + +[tool.setuptools.package-dir] +"" = "src" + +[tool.setuptools.packages.find] +where = ["src"] +include = ["shared_core*", "shared_handler*", "shared_storage*"] + +[tool.pytest.ini_options] +pythonpath = ["src"] + +[tool.pyrefly] +python-version = "3.13" +untyped-def-behavior = "check-and-infer-return-any" +search-path = [".", "../shared/python/src"] +venv = ".venv" + +[tool.pyrefly.errors] +unannotated-parameter = "error" +unannotated-return = "error" +unannotated-attribute = "error" +implicit-any = "error" + +[dependency-groups] +dev = [ + "pyrefly>=0.62.0", + "pytest>=9.0.3", + "pytest-cov>=7.1.0", + "ruff>=0.15.11", + "testcontainers[nats]>=4.14.2", +] diff --git a/backend/scene-detector/src/core/logging.py b/backend/shared/python/src/shared_core/logging.py similarity index 76% rename from backend/scene-detector/src/core/logging.py rename to backend/shared/python/src/shared_core/logging.py index 2d38adb..2c18a60 100644 --- a/backend/scene-detector/src/core/logging.py +++ b/backend/shared/python/src/shared_core/logging.py @@ -5,7 +5,7 @@ def configure_logging() -> None: - """Initialize the structured logger""" + """Initialize structlog, call once at service startup""" level = logging.DEBUG if settings.LOG_LEVEL == "DEBUG" else logging.INFO logging.basicConfig(stream=sys.stdout, level=level) @@ -22,8 +22,8 @@ def configure_logging() -> None: structlog.configure(processors=processors) -logger: structlog.stdlib.BoundLogger = structlog.get_logger().bind( - service="scene-detector" -) +def get_logger(service_name: str) -> structlog.stdlib.BoundLogger: + return structlog.get_logger().bind(service=service_name) + configure_logging() diff --git a/backend/shared/python/src/shared_core/settings.py b/backend/shared/python/src/shared_core/settings.py new file mode 100644 index 0000000..48cbae5 --- /dev/null +++ b/backend/shared/python/src/shared_core/settings.py @@ -0,0 +1,24 @@ +from pathlib import Path +from pydantic_settings import BaseSettings + +PROJECT_ROOT = Path(__file__).parent.parent.parent +ENV_FILE = PROJECT_ROOT / ".env" + + +class Settings(BaseSettings): + # general config + LOG_LEVEL: str = "DEBUG" + LOG_FORMAT: str = "json" + + # Nats config + NATS_URL: str = "nats://localhost:4222" + MAX_RECONNECT_ATTEMPT: int = 5 + RECONNECT_TIME_WAIT_S: int = 2 + KV_BUCKET_TTL_S: int = 3 * 60 * 60 # 3 hour TTL + MAX_DELIVER_ATTEMPTS: int = 3 + ACK_WAIT_S: int = 30 + + BASE_STORAGE_URL: str = "http://localhost:8888" + + +settings = Settings() diff --git a/backend/shared/python/src/shared_handler/connection.py b/backend/shared/python/src/shared_handler/connection.py new file mode 100644 index 0000000..36fd7bf --- /dev/null +++ b/backend/shared/python/src/shared_handler/connection.py @@ -0,0 +1,52 @@ +from shared_core.logging import get_logger +from shared_core.settings import settings +from nats.js.client import JetStreamContext +from nats.aio.client import Client as NATSClient +import nats.js.errors as js_errors + + +async def check_js_stream_exists(js: JetStreamContext, subject_name: str) -> None: + """ + Check if a js stream exists using the subject name. Used before trying to + connect to the stream in order to fail early + + Args: + js: the jetstream context connection + subject_name: the stream subject name we are checking + + Raises: + RuntimeError if the jetstream stream doesnt exist + """ + try: + await js.find_stream_name_by_subject(subject_name) + except js_errors.NotFoundError: + raise RuntimeError(f"No stream found for `{subject_name}`") + + +async def nats_connect(service_name: str) -> tuple[NATSClient, JetStreamContext]: + """nats connection and jetstream context required for pub/sub""" + nats_url = settings.NATS_URL + logger = get_logger(service_name) + + async def _on_reconnect() -> None: + logger.debug("reconnected to nats") + + async def _on_disconnect() -> None: + logger.warning("disconnected from nats") + + async def _on_error(err: Exception) -> None: + logger.error("error connecting to nats", err=str(err)) + + nats_client = NATSClient() + await nats_client.connect( + nats_url, + max_reconnect_attempts=settings.MAX_RECONNECT_ATTEMPT, + reconnect_time_wait=settings.RECONNECT_TIME_WAIT_S, + reconnected_cb=_on_reconnect, + disconnected_cb=_on_disconnect, + error_cb=_on_error, + ) + + jetstream_client: JetStreamContext = nats_client.jetstream() + + return nats_client, jetstream_client diff --git a/backend/scene-detector/src/handler/http_server.py b/backend/shared/python/src/shared_handler/http.py similarity index 100% rename from backend/scene-detector/src/handler/http_server.py rename to backend/shared/python/src/shared_handler/http.py index f19c14e..f882c69 100644 --- a/backend/scene-detector/src/handler/http_server.py +++ b/backend/shared/python/src/shared_handler/http.py @@ -1,7 +1,7 @@ from http.server import HTTPServer from http.server import BaseHTTPRequestHandler -import threading import json +import threading class HealthEnpointHandler(BaseHTTPRequestHandler): diff --git a/backend/shared/python/src/shared_handler/kv.py b/backend/shared/python/src/shared_handler/kv.py new file mode 100644 index 0000000..6b2de25 --- /dev/null +++ b/backend/shared/python/src/shared_handler/kv.py @@ -0,0 +1,94 @@ +from nats.js.kv import KeyValue +from nats.js import JetStreamContext +from nats.js.api import KeyValueConfig +from nats.js.errors import KeyNotFoundError +from shared_core.logging import get_logger +from shared_core.settings import settings +import json +import nats.js.errors as js_errors + + +async def connect_kv(js: JetStreamContext, kv_name: str) -> KeyValue: + """ + Connect to an existing jetstream kv + + Args: + js: jetstreamContext server we are connecting to + kv_name: the kv we are trying to connect to + + Returns: + the Jetstream KeyValue connection + + Raises: + RuntimeError if the Jetstream KV isnt found + """ + try: + job_status_kv = await js.key_value(kv_name) + + return job_status_kv + except js_errors.NotFoundError: + raise RuntimeError( + "job-status KV bucket not found, check video-status is running" + ) + + +async def create_kv(js: JetStreamContext, bucket_name: str) -> KeyValue: + """ + Create a new Jetstream KV + + Args: + js: jetstreamContext server we creating the new KV on + kv_name: the kv we are trying to create + + Returns: + the Jetstream KeyValue connection + + Raises: + RuntimeError if the a API error happens with jetstream + """ + try: + msg_processed_kv = await js.create_key_value( + config=KeyValueConfig(bucket=bucket_name, ttl=settings.KV_BUCKET_TTL_S) + ) + + return msg_processed_kv + + except js_errors.APIError as e: + raise RuntimeError(f"failed to create {bucket_name} KV bucket: {e}") + + +async def check_already_processed(kv: KeyValue, job_id: str) -> bool: + """Checks if the job_id exists in the kv so it doesnt reprocess""" + try: + await kv.get(job_id) + return True + + except KeyNotFoundError: + return False + + +async def update_job_status( + job_status_kv: KeyValue, + job_id: str, + stage: str, + service_name: str, + progress: int | None = None, +) -> None: + """ + Writes PROCESSING for the stage to the job-status KV bucket + + Args: + + Exception: + logs the error + """ + logger = get_logger(service_name) + + try: + payload: dict[str, str | int] = {"state": "PROCESSING", "stage": stage} + if progress is not None: + payload["progress"] = progress + status = json.dumps(payload).encode() + await job_status_kv.put(job_id, status) + except Exception as e: + logger.error("failed to update job status stage", job_id=job_id, err=str(e)) diff --git a/backend/scene-detector/src/handler/messages.py b/backend/shared/python/src/shared_handler/messages.py similarity index 67% rename from backend/scene-detector/src/handler/messages.py rename to backend/shared/python/src/shared_handler/messages.py index 0de6ce3..f4e2181 100644 --- a/backend/scene-detector/src/handler/messages.py +++ b/backend/shared/python/src/shared_handler/messages.py @@ -1,16 +1,20 @@ from pydantic import BaseModel -# typed class for messages in the nats jetstream -class SceneSplitMessage(BaseModel): +class VideoChunkMessage(BaseModel): job_id: str + chunk_index: int + total_chunks: int storage_url: str target_resolution: str -class VideoChunkMessage(BaseModel): +class ProcessJobMessage(BaseModel): job_id: str - chunk_index: int - total_chunks: int storage_url: str + source_resolution: str target_resolution: str + + +class UpscaleCompleteMsg(BaseModel): + job_id: str diff --git a/backend/shared/python/src/shared_handler/nats.py b/backend/shared/python/src/shared_handler/nats.py new file mode 100644 index 0000000..d99fa5c --- /dev/null +++ b/backend/shared/python/src/shared_handler/nats.py @@ -0,0 +1,68 @@ +from typing import Awaitable +from typing import Callable +from nats.aio.msg import Msg +from nats.js.kv import KeyValue +from nats.js.api import ConsumerConfig +from nats.errors import TimeoutError +from nats.js.errors import APIError +from nats.js.client import JetStreamContext +from shared_core.logging import get_logger +from shared_core.settings import settings +from shared_handler.messages import UpscaleCompleteMsg +from .messages import VideoChunkMessage + + +async def consumer( + js: JetStreamContext, + msg_processed_kv: KeyValue, + job_status_kv: KeyValue, + sub_subject: str, + durable_name: str, + queue_name: str, + process_msg: Callable[[JetStreamContext, KeyValue, KeyValue, Msg], Awaitable[None]], +) -> None: + """Nats jetstream consumer that subscribes to subject to process videos""" + sub = await js.subscribe( + subject=sub_subject, + durable=durable_name, + queue=queue_name, + config=ConsumerConfig( + max_deliver=settings.MAX_DELIVER_ATTEMPTS, ack_wait=settings.ACK_WAIT_S + ), + ) + + async for msg in sub.messages: + await process_msg(js, msg_processed_kv, job_status_kv, msg) + + +async def publisher( + js: JetStreamContext, + msg: VideoChunkMessage | UpscaleCompleteMsg, + subject: str, + service_name: str, +) -> None: + """ + Publishes message to nats jetstream + + Args: + js: the jetstream context with connection info for publishing + msg: the actual data we are publishing to the broker + subject: the jetstream subject we want to publish to + service_name: the service name to log with + + Raises: + TimeoutError: when publishing times out, logs and raises + APIError: when an jetstream api error is recieved when trying + to publish, logs and raises + """ + logger = get_logger(service_name) + + try: + await js.publish(subject=subject, payload=msg.model_dump_json().encode()) + logger.debug("pub msg to nats jetstream successfully") + except TimeoutError as e: + logger.error("timed out publishing msg", job_id=msg.job_id, err=str(e)) + raise + except APIError as e: + logger.error("jetstream error publishing msg", job_id=msg.job_id, err=str(e)) + raise diff --git a/backend/scene-detector/src/storage/check_health.py b/backend/shared/python/src/shared_storage/check_health.py similarity index 73% rename from backend/scene-detector/src/storage/check_health.py rename to backend/shared/python/src/shared_storage/check_health.py index 78ac100..4420100 100644 --- a/backend/scene-detector/src/storage/check_health.py +++ b/backend/shared/python/src/shared_storage/check_health.py @@ -1,16 +1,21 @@ -from ..core.logging import logger -from ..core.settings import settings +from shared_core.logging import get_logger +from shared_core.settings import settings import requests -def check_storage_health() -> None: +def check_storage_health(service_name: str) -> None: """ Check if seaweedfs filer is reachable + Args: + service_name: the service name to log with + Raises: requests.ConnectionError: if seaweedfs is unreachable requests.HTTPError: if seaweedfs filer returns a 5xx error """ + logger = get_logger(service_name) + try: response = requests.get(settings.BASE_STORAGE_URL + "/") response.raise_for_status() diff --git a/backend/shared/python/src/shared_storage/queries.py b/backend/shared/python/src/shared_storage/queries.py new file mode 100644 index 0000000..ba45c20 --- /dev/null +++ b/backend/shared/python/src/shared_storage/queries.py @@ -0,0 +1,106 @@ +from shared_core.logging import get_logger +import os +import requests + +TEMP_DIR: str = "../temp" + + +def fetch_video(storage_url: str, service_name: str) -> str: + """ + Fetch unprocessed video from seaweedfs storage for processing + and save it locally for processing + + Args: + storage_url: full SeaweedFS URL to the video, from NATS + service_name: the service name to log with + + Raises: + requests.ConnectionError if SeaweedFS is unreachable + request.HTTPError: if SeaweedFS returns 404 or 5xx + + Returns: + dest_path string on success + """ + logger = get_logger(service_name) + + try: + response = requests.get(storage_url) + response.raise_for_status() + except requests.ConnectionError as e: + logger.error( + "could not connect to seaweedfs", storage_url=storage_url, err=str(e) + ) + raise + except requests.HTTPError as e: + logger.error( + "seaweedfs returned error fetching video", + storage_url=storage_url, + status_code=e.response.status_code, + err=str(e), + ) + raise + + parts = storage_url.rstrip("/").split("/") + dest_path: str = f"{TEMP_DIR}/{parts[-2]}/{parts[-1]}" + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + with open(dest_path, "wb") as f: + f.write(response.content) + + return dest_path + + +def upload_video( + storage_url: str, job_id: str, video_path: str, service_name: str +) -> str: + """ + Upload a single video to seaweedfs storage + + Args: + storage_url: the storage url to upload to on the shared storage + job_id: job_id for one request from NATS + video_path: local file path for the video + service_name: the service name to log with + + Raises: + FileNotFoundError: if the local chunk file is missing before upload + requests.ConnectionError: If SeaweedFS is unreachable + requests.HTTPError: If SeaweedFS returns 4xx/5xx on upload + + Returns: + SeaweedFS storage URL for the uploaded video + """ + logger = get_logger(service_name) + + if not os.path.exists(video_path): + logger.error( + "video file not found before upload", + chunk_path=video_path, + job_id=job_id, + ) + raise FileNotFoundError(f"video file not found: {video_path}") + + try: + with open(video_path, "rb") as f: + response = requests.put( + storage_url, + data=f, + headers={"Content-Type": "application/octet-stream"}, + ) + response.raise_for_status() + except requests.ConnectionError as e: + logger.error( + "could not connect to seaweedfs", url=storage_url, job_id=job_id, err=str(e) + ) + raise + except requests.HTTPError as e: + logger.error( + "seaweedfs returned error uploading video", + url=storage_url, + job_id=job_id, + status_code=e.response.status_code, + err=str(e), + ) + raise + + logger.debug("uploaded video to seaweedfs", job_id=job_id, url=storage_url) + return storage_url diff --git a/backend/scene-detector/src/handler/__init__.py b/backend/shared/python/tests/__init__.py similarity index 100% rename from backend/scene-detector/src/handler/__init__.py rename to backend/shared/python/tests/__init__.py diff --git a/backend/shared/python/tests/conftest.py b/backend/shared/python/tests/conftest.py new file mode 100644 index 0000000..00b291d --- /dev/null +++ b/backend/shared/python/tests/conftest.py @@ -0,0 +1,6 @@ +# references to fixture files +pytest_plugins = [ + "tests.fixtures.nats", + "tests.fixtures.http", + "tests.fixtures.storage", +] diff --git a/backend/shared/python/tests/fixtures/http.py b/backend/shared/python/tests/fixtures/http.py new file mode 100644 index 0000000..fa39c06 --- /dev/null +++ b/backend/shared/python/tests/fixtures/http.py @@ -0,0 +1,18 @@ +from typing import Any +from socket import socket +from shared_handler.http import start_health_server +import pytest + + +def _free_port() -> int: + with socket() as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def live_http_server() -> Any: + port = _free_port() + server = start_health_server(port) + yield f"http://localhost:{port}" + server.shutdown() diff --git a/backend/shared/python/tests/fixtures/nats.py b/backend/shared/python/tests/fixtures/nats.py new file mode 100644 index 0000000..59050b6 --- /dev/null +++ b/backend/shared/python/tests/fixtures/nats.py @@ -0,0 +1,52 @@ +from nats.aio.msg import Msg +from nats.js.api import KeyValueConfig +from typing import Any +from typing import Generator +from typing import AsyncGenerator +from nats.js import JetStreamContext +from testcontainers.nats import NatsContainer +import nats # type: ignore[import-untyped] +import json +import pytest +import pytest_asyncio + + +@pytest.fixture(scope="session") +def nats_url() -> Generator[str, None]: + """Starts a nats container and returns url""" + with NatsContainer(jetstream=True) as container: + yield container.nats_uri() + + +@pytest_asyncio.fixture +async def js_context( + nats_url: str, +) -> AsyncGenerator[tuple[Any, JetStreamContext], None]: + nc = await nats.connect(nats_url) # type: ignore[import-untyped] + js = nc.jetstream() + try: + await js.delete_stream("videos") + except Exception: + pass + await js.add_stream( + name="videos", + subjects=["jobs.video.scene-split", "jobs.video.chunks"], + ) + await js.create_key_value(config=KeyValueConfig(bucket="job-status")) + yield nc, js + await nc.close() + + +@pytest_asyncio.fixture +async def nats_video_chunks_subscriber( + js_context: tuple[Any, JetStreamContext], +) -> AsyncGenerator[list[Any], None]: + nc, js = js_context + received = [] + + async def handler(msg: Msg) -> None: + received.append(json.loads(msg.data.decode())) + + sub = await nc.subscribe("jobs.video.chunks", cb=handler) + yield received + await sub.unsubscribe() diff --git a/backend/shared/python/tests/fixtures/storage.py b/backend/shared/python/tests/fixtures/storage.py new file mode 100644 index 0000000..e114f8c --- /dev/null +++ b/backend/shared/python/tests/fixtures/storage.py @@ -0,0 +1,97 @@ +from typing import Any +from shared_storage import queries +from pathlib import Path +from typing import Generator, Tuple +from testcontainers.core.container import DockerContainer +import requests +import pytest +import uuid +import time +import os + +TEST_VIDEO_PATH = os.path.join( + os.path.dirname(__file__), "..", "videos", "ForBiggerBlazes.mp4" +) +TEST_VIDEO_FILENAME = "ForBiggerBlazes.mp4" + + +@pytest.fixture(autouse=True) +def patch_temp_dir(tmp_path: Any, monkeypatch: Any) -> None: + monkeypatch.setattr(queries, "TEMP_DIR", str(tmp_path)) + + +def _wait_for_seaweedfs( + host: str, master_port: int, filer_port: int, timeout: int = 60 +) -> None: + """Poll SeaweedFS master and filer HTTP endpoints until both are ready.""" + endpoints = [ + f"http://{host}:{master_port}/dir/status", + f"http://{host}:{filer_port}/", + ] + for url in endpoints: + deadline = time.time() + timeout + while time.time() < deadline: + try: + resp = requests.get(url, timeout=2) + if resp.status_code < 500: + break + except Exception: + pass + time.sleep(1) + else: + raise TimeoutError(f"SeaweedFS not ready at {url} after {timeout}s") + + +@pytest.fixture(scope="session") +def seaweedfs_url() -> Generator[str, None, None]: + """Starts a SeaweedFS container and yields the filer base URL (http://host:8888)""" + with ( + DockerContainer("chrislusf/seaweedfs") + .with_command("server -dir=/data -master.port=9333 -volume.port=8080 -filer") + .with_exposed_ports(9333, 8888) + ) as container: + host = container.get_container_host_ip() + master_port = container.get_exposed_port(9333) + filer_port = container.get_exposed_port(8888) + _wait_for_seaweedfs(host, master_port, filer_port) + yield f"http://{host}:{filer_port}" + + +@pytest.fixture +def seeded_video(seaweedfs_url: str) -> Generator[Tuple[str, str], None, None]: + """Seeds ForBiggerBlazes.mp4 into SeaweedFS and yields (job_id, storage_url)""" + job_id = str(uuid.uuid4()) + storage_url = f"{seaweedfs_url}/{job_id}/{TEST_VIDEO_FILENAME}" + + with open(TEST_VIDEO_PATH, "rb") as f: + response = requests.put( + storage_url, + data=f, + headers={"Content-Type": "application/octet-stream"}, + ) + response.raise_for_status() + + yield job_id, storage_url + + +@pytest.fixture +def fake_base_url() -> str: + return "http://fake:8888" + + +@pytest.fixture +def chunk_files(tmp_path: Path) -> list[str]: + """Creates a set of fake .mp4 chunk files in tmp_path""" + chunks = [] + for i in range(3): + chunk = tmp_path / f"video-Scene-{i + 1:03d}.mp4" + chunk.write_bytes(b"fake chunk content") + chunks.append(str(chunk)) + return chunks + + +@pytest.fixture +def single_video_chunk(tmp_path: Path) -> str: + chunk = tmp_path / "chunk.mp4" + chunk.write_bytes(b"data") + return str(chunk) diff --git a/backend/scene-detector/tests/integration/test_check_health.py b/backend/shared/python/tests/integration/test_check_health.py similarity index 67% rename from backend/scene-detector/tests/integration/test_check_health.py rename to backend/shared/python/tests/integration/test_check_health.py index a4910f1..01ee9cd 100644 --- a/backend/scene-detector/tests/integration/test_check_health.py +++ b/backend/shared/python/tests/integration/test_check_health.py @@ -1,5 +1,5 @@ from unittest.mock import patch, MagicMock -from src.storage.check_health import check_storage_health +from shared_storage.check_health import check_storage_health import requests import pytest @@ -9,9 +9,9 @@ def test_check_health_succeeds( ) -> None: """Passes without raising when SeaweedFS master and filer are reachable""" monkeypatch.setattr( - "src.storage.check_health.settings.BASE_STORAGE_URL", seaweedfs_url + "shared_storage.check_health.settings.BASE_STORAGE_URL", seaweedfs_url ) - check_storage_health() + check_storage_health(service_name="scene-detector") @pytest.mark.parametrize( @@ -25,9 +25,11 @@ def test_check_health_raises_on_connection_error( bad_url: str, monkeypatch: pytest.MonkeyPatch ) -> None: """Raises ConnectionError when SeaweedFS is unreachable""" - monkeypatch.setattr("src.storage.check_health.settings.BASE_STORAGE_URL", bad_url) + monkeypatch.setattr( + "shared_storage.check_health.settings.BASE_STORAGE_URL", bad_url + ) with pytest.raises(requests.ConnectionError): - check_storage_health() + check_storage_health(service_name="scene-detector") @pytest.mark.parametrize("status_code", [500, 502, 503]) @@ -40,7 +42,7 @@ def test_check_health_raises_on_server_error(status_code: int) -> None: ) with ( - patch("src.storage.check_health.requests.get", return_value=mock_response), + patch("shared_storage.check_health.requests.get", return_value=mock_response), pytest.raises(requests.HTTPError), ): - check_storage_health() + check_storage_health(service_name="scene-detector") diff --git a/backend/shared/python/tests/integration/test_consumer.py b/backend/shared/python/tests/integration/test_consumer.py new file mode 100644 index 0000000..5ec90db --- /dev/null +++ b/backend/shared/python/tests/integration/test_consumer.py @@ -0,0 +1,44 @@ +from typing import Any +from unittest.mock import AsyncMock +from nats.js.api import KeyValueConfig +from nats.js.client import JetStreamContext +from shared_handler.nats import consumer +import pytest +import asyncio + + +@pytest.mark.asyncio +async def test_calls_process_msg_for_published_message( + js_context: tuple[Any, JetStreamContext], +) -> None: + """Verifies consumer receives a message and calls process_msg""" + nc, js = js_context + + kv = await js.create_key_value( + config=KeyValueConfig(bucket="test-consumer-status-1") + ) + job_status_kv = await js.create_key_value( + config=KeyValueConfig(bucket="test-consumer-job-status-1") + ) + process_msg = AsyncMock() + + task = asyncio.create_task( + consumer( + js, + kv, + job_status_kv, + "jobs.video.scene-split", + "test-consumer", + "test-consumer", + process_msg, + ) + ) + await nc.publish("jobs.video.scene-split", b"test-payload") + await asyncio.sleep(0.5) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert process_msg.call_count == 1 diff --git a/backend/scene-detector/tests/integration/test_fetch_video.py b/backend/shared/python/tests/integration/test_fetch_video.py similarity index 79% rename from backend/scene-detector/tests/integration/test_fetch_video.py rename to backend/shared/python/tests/integration/test_fetch_video.py index 5de2583..7d86727 100644 --- a/backend/scene-detector/tests/integration/test_fetch_video.py +++ b/backend/shared/python/tests/integration/test_fetch_video.py @@ -1,5 +1,5 @@ from typing import Tuple -from src.storage.queries import fetch_video +from shared_storage.queries import fetch_video import requests import pytest import os @@ -8,7 +8,7 @@ def test_fetch_video_downloads_file(seeded_video: Tuple[str, str]) -> None: """File is downloaded and saved locally with the correct filename""" job_id, storage_url = seeded_video - local_path = fetch_video(storage_url) + local_path = fetch_video(storage_url, service_name="scene-detector") assert os.path.exists(local_path) assert os.path.getsize(local_path) > 0 @@ -18,7 +18,7 @@ def test_fetch_video_downloads_file(seeded_video: Tuple[str, str]) -> None: def test_fetch_video_creates_directory(seeded_video: Tuple[str, str]) -> None: """Destination directory is created if it does not already exist""" job_id, storage_url = seeded_video - local_path = fetch_video(storage_url) + local_path = fetch_video(storage_url, service_name="scene-detector") assert os.path.isdir(os.path.dirname(local_path)) @@ -26,7 +26,7 @@ def test_fetch_video_creates_directory(seeded_video: Tuple[str, str]) -> None: def test_fetch_video_path_namespaced_by_job_id(seeded_video: Tuple[str, str]) -> None: """Local path includes the job_id segment to prevent collisions across jobs""" job_id, storage_url = seeded_video - local_path = fetch_video(storage_url) + local_path = fetch_video(storage_url, service_name="scene-detector") assert job_id in local_path @@ -35,7 +35,7 @@ def test_fetch_video_raises_on_404(seaweedfs_url: str) -> None: """Raises HTTPError when the video does not exist in storage""" missing_url = f"{seaweedfs_url}/nonexistent-job/missing.mp4" with pytest.raises(requests.HTTPError): - fetch_video(missing_url) + fetch_video(missing_url, service_name="scene-detector") @pytest.mark.parametrize( @@ -48,4 +48,4 @@ def test_fetch_video_raises_on_404(seaweedfs_url: str) -> None: def test_fetch_video_raises_on_connection_error(bad_url: str) -> None: """Raises ConnectionError when SeaweedFS is unreachable""" with pytest.raises(requests.ConnectionError): - fetch_video(bad_url) + fetch_video(bad_url, service_name="scene-detector") diff --git a/backend/scene-detector/tests/integration/test_http_server.py b/backend/shared/python/tests/integration/test_http_server.py similarity index 100% rename from backend/scene-detector/tests/integration/test_http_server.py rename to backend/shared/python/tests/integration/test_http_server.py diff --git a/backend/scene-detector/tests/integration/test_nats_connect.py b/backend/shared/python/tests/integration/test_nats_connect.py similarity index 66% rename from backend/scene-detector/tests/integration/test_nats_connect.py rename to backend/shared/python/tests/integration/test_nats_connect.py index 83ddb95..a365747 100644 --- a/backend/scene-detector/tests/integration/test_nats_connect.py +++ b/backend/shared/python/tests/integration/test_nats_connect.py @@ -1,7 +1,7 @@ from typing import Any from nats.aio.client import Client as NATSClient from nats.js.client import JetStreamContext -from src.handler.connection import nats_connect +from shared_handler.connection import nats_connect import pytest @@ -9,9 +9,9 @@ async def test_connect_returns_connected_clients( nats_url: str, monkeypatch: Any ) -> None: - monkeypatch.setattr("src.handler.connection.settings.NATS_URL", nats_url) + monkeypatch.setattr("shared_handler.connection.settings.NATS_URL", nats_url) - nc, js = await nats_connect() + nc, js = await nats_connect(service_name="scene-detector") assert isinstance(nc, NATSClient) assert isinstance(js, JetStreamContext) diff --git a/backend/scene-detector/tests/integration/test_publisher.py b/backend/shared/python/tests/integration/test_publisher.py similarity index 85% rename from backend/scene-detector/tests/integration/test_publisher.py rename to backend/shared/python/tests/integration/test_publisher.py index 092c274..8928fbc 100644 --- a/backend/scene-detector/tests/integration/test_publisher.py +++ b/backend/shared/python/tests/integration/test_publisher.py @@ -1,7 +1,7 @@ from typing import Any from nats.js.client import JetStreamContext -from src.handler.messages import VideoChunkMessage -from src.handler.publisher import scene_video_chunks +from shared_handler.messages import VideoChunkMessage +from shared_handler.nats import publisher import pytest @@ -28,7 +28,8 @@ async def test_publishes_all_messages_with_correct_payload( ), ] - await scene_video_chunks(js, MSGS) + for msg in MSGS: + await publisher(js, msg, "jobs.video.chunks", service_name="scene-detector") assert len(nats_video_chunks_subscriber) == 2 assert nats_video_chunks_subscriber[0] == { diff --git a/backend/shared/python/tests/integration/test_upload_video.py b/backend/shared/python/tests/integration/test_upload_video.py new file mode 100644 index 0000000..894ba48 --- /dev/null +++ b/backend/shared/python/tests/integration/test_upload_video.py @@ -0,0 +1,37 @@ +from pathlib import Path +from shared_storage.queries import upload_video +import os +import requests + + +def test_upload_chunks_happy_path(seaweedfs_url: str, chunk_files: list[str]) -> None: + """All chunks are uploaded and returned URLs are reachable via GET""" + job_id = "test-job-upload" + + storage_urls = [ + upload_video( + f"{seaweedfs_url}/{job_id}/{os.path.basename(path)}", + job_id, + path, + service_name="scene-detector", + ) + for path in chunk_files + ] + + assert len(storage_urls) == len(chunk_files) + for url in storage_urls: + assert url.startswith(f"{seaweedfs_url}/{job_id}/") + resp = requests.get(url) + assert resp.status_code == 200 + + +def test_upload_single_chunk(seaweedfs_url: str, tmp_path: Path) -> None: + """single chunk uploads and returns one URL""" + job_id = "single-job" + chunk = tmp_path / "video-Scene-001.mp4" + chunk.write_bytes(b"single chunk") + storage_url = f"{seaweedfs_url}/{job_id}/video-Scene-001.mp4" + + url = upload_video(storage_url, job_id, str(chunk), service_name="scene-detector") + + assert "video-Scene-001.mp4" in url diff --git a/backend/shared/python/tests/unit/test_consumer.py b/backend/shared/python/tests/unit/test_consumer.py new file mode 100644 index 0000000..e6265c8 --- /dev/null +++ b/backend/shared/python/tests/unit/test_consumer.py @@ -0,0 +1,77 @@ +from typing import Any, AsyncGenerator +from unittest.mock import AsyncMock, MagicMock +from nats.js.errors import APIError +from nats.js.client import JetStreamContext +from nats.js.kv import KeyValue +from shared_handler.nats import consumer +import pytest + + +async def async_iter(items: Any) -> AsyncGenerator[Any, None]: + for item in items: + yield item + + +def make_mock_js(*msgs: AsyncMock) -> AsyncMock: + js = AsyncMock(spec=JetStreamContext) + sub = MagicMock() + sub.messages = async_iter(list(msgs)) + js.subscribe.return_value = sub + return js + + +@pytest.mark.asyncio +async def test_calls_process_msg_once_per_message() -> None: + msgs = [AsyncMock(), AsyncMock()] + mock_js = make_mock_js(*msgs) + mock_process_msg = AsyncMock() + + await consumer( + mock_js, + AsyncMock(spec=KeyValue), + AsyncMock(spec=KeyValue), + "idk", + "idk2", + "idk2", + mock_process_msg, + ) + + assert mock_process_msg.call_count == 2 + + +@pytest.mark.asyncio +async def test_passes_correct_args_to_process_msg() -> None: + mock_kv = AsyncMock(spec=KeyValue) + mock_job_status_kv = AsyncMock(spec=KeyValue) + msg = AsyncMock() + mock_js = make_mock_js(msg) + mock_process_msg = AsyncMock() + + await consumer( + mock_js, + mock_kv, + mock_job_status_kv, + "subject", + "durable", + "queue", + mock_process_msg, + ) + + mock_process_msg.assert_called_once_with(mock_js, mock_kv, mock_job_status_kv, msg) + + +@pytest.mark.asyncio +async def test_raises_when_subscribe_fails() -> None: + mock_js = AsyncMock(spec=JetStreamContext) + mock_js.subscribe.side_effect = APIError() + + with pytest.raises(APIError): + await consumer( + mock_js, + AsyncMock(spec=KeyValue), + AsyncMock(spec=KeyValue), + "idk1", + "idk2", + "idk2", + AsyncMock(), + ) diff --git a/backend/scene-detector/tests/unit/test_fetch_video.py b/backend/shared/python/tests/unit/test_fetch_video.py similarity index 70% rename from backend/scene-detector/tests/unit/test_fetch_video.py rename to backend/shared/python/tests/unit/test_fetch_video.py index 02c8e7b..193eca9 100644 --- a/backend/scene-detector/tests/unit/test_fetch_video.py +++ b/backend/shared/python/tests/unit/test_fetch_video.py @@ -1,8 +1,8 @@ from unittest.mock import patch from unittest.mock import MagicMock from pathlib import Path -from src.storage.queries import fetch_video -import src.storage.queries as queries +from shared_storage.queries import fetch_video +import shared_storage.queries as queries import requests import pytest @@ -17,10 +17,10 @@ def test_fetch_video_raises_on_server_error(status_code: int) -> None: ) with ( - patch("src.storage.queries.requests.get", return_value=mock_response), + patch("shared_storage.queries.requests.get", return_value=mock_response), pytest.raises(requests.HTTPError), ): - fetch_video("http://fake/job-id/video.mp4") + fetch_video("http://fake/job-id/video.mp4", service_name="scene-detector") def test_fetch_video_writes_correct_content( @@ -34,8 +34,10 @@ def test_fetch_video_writes_correct_content( mock_response.content = fake_content monkeypatch.setattr(queries, "TEMP_DIR", str(tmp_path)) - with patch("src.storage.queries.requests.get", return_value=mock_response): - local_path = fetch_video("http://fake/job-123/video.mp4") + with patch("shared_storage.queries.requests.get", return_value=mock_response): + local_path = fetch_video( + "http://fake/job-123/video.mp4", service_name="scene-detector" + ) with open(local_path, "rb") as f: assert f.read() == fake_content diff --git a/backend/scene-detector/tests/unit/test_http_server.py b/backend/shared/python/tests/unit/test_http_server.py similarity index 79% rename from backend/scene-detector/tests/unit/test_http_server.py rename to backend/shared/python/tests/unit/test_http_server.py index 98a7a4d..88ca351 100644 --- a/backend/scene-detector/tests/unit/test_http_server.py +++ b/backend/shared/python/tests/unit/test_http_server.py @@ -1,7 +1,8 @@ from typing import Any from http.server import HTTPServer from unittest.mock import MagicMock, create_autospec, patch -from src.handler.http_server import HealthEnpointHandler, start_health_server +from shared_handler.http import start_health_server +from shared_handler.http import HealthEnpointHandler import json import pytest import threading @@ -37,9 +38,6 @@ def test_endpoint( handler.wfile.write.assert_not_called() -# ── server startup ──────────────────────────────────────────────────────────── - - def test_start_health_server() -> None: mock_server = MagicMock(spec=HTTPServer) real_thread_cls = threading.Thread @@ -53,8 +51,8 @@ def capture_thread(**kwargs: object) -> MagicMock: return t with ( - patch("src.handler.http_server.HTTPServer", return_value=mock_server), - patch("src.handler.http_server.threading.Thread", side_effect=capture_thread), + patch("shared_handler.http.HTTPServer", return_value=mock_server), + patch("shared_handler.http.threading.Thread", side_effect=capture_thread), ): result = start_health_server(9099) diff --git a/backend/scene-detector/tests/unit/test_nats_connect.py b/backend/shared/python/tests/unit/test_nats_connect.py similarity index 76% rename from backend/scene-detector/tests/unit/test_nats_connect.py rename to backend/shared/python/tests/unit/test_nats_connect.py index eb7f376..ec02629 100644 --- a/backend/scene-detector/tests/unit/test_nats_connect.py +++ b/backend/shared/python/tests/unit/test_nats_connect.py @@ -7,7 +7,7 @@ from nats.errors import NoServersError from nats.errors import AuthorizationError from nats.js.client import JetStreamContext -from src.handler.connection import nats_connect +from shared_handler.connection import nats_connect import pytest @@ -17,12 +17,12 @@ ) async def test_connect_raises_on_nats_failure(exc: Any) -> None: """It should raise the error when caught""" - with patch("src.handler.connection.NATSClient") as mock_client_class: + with patch("shared_handler.connection.NATSClient") as mock_client_class: mock_instance = MagicMock(spec=NATSClient) mock_instance.connect = AsyncMock(side_effect=exc) mock_client_class.return_value = mock_instance with pytest.raises(type(exc)): - await nats_connect() + await nats_connect(service_name="scene-detector") @pytest.mark.asyncio @@ -32,8 +32,8 @@ async def test_connect_returns_nats_and_jetstream() -> None: mock_ns.connect = AsyncMock() mock_ns.jetstream.return_value = mock_js - with patch("src.handler.connection.NATSClient", return_value=mock_ns): - nc, js = await nats_connect() + with patch("shared_handler.connection.NATSClient", return_value=mock_ns): + nc, js = await nats_connect(service_name="scene-detector") assert nc is mock_ns assert js is mock_js diff --git a/backend/scene-detector/tests/unit/test_publisher.py b/backend/shared/python/tests/unit/test_publisher.py similarity index 51% rename from backend/scene-detector/tests/unit/test_publisher.py rename to backend/shared/python/tests/unit/test_publisher.py index bd0b153..d8b841c 100644 --- a/backend/scene-detector/tests/unit/test_publisher.py +++ b/backend/shared/python/tests/unit/test_publisher.py @@ -3,8 +3,8 @@ from nats.errors import TimeoutError from nats.js.errors import APIError from nats.js.client import JetStreamContext -from src.handler.publisher import scene_video_chunks -from src.handler.messages import VideoChunkMessage +from shared_handler.nats import publisher +from shared_handler.messages import VideoChunkMessage import pytest @@ -15,18 +15,20 @@ async def test_raises_on_publish_failure(exc: Any) -> None: mock_js.publish.side_effect = exc with pytest.raises(type(exc)): - await scene_video_chunks( - mock_js, - [ - VideoChunkMessage( - job_id="1", - chunk_index=0, - total_chunks=1, - storage_url="/fake/path.mp4", - target_resolution="480p", - ) - ], - ) + msgs = [ + VideoChunkMessage( + job_id="1", + chunk_index=0, + total_chunks=1, + storage_url="/fake/path.mp4", + target_resolution="480p", + ) + ] + + for msg in msgs: + await publisher( + mock_js, msg, "jobs.video.chunks", service_name="scene-detector" + ) @pytest.mark.asyncio @@ -34,24 +36,26 @@ async def test_calls_publish_per_msg() -> None: """publish should be called as many times as the amount of items in the input list""" mock_js = AsyncMock(spec=JetStreamContext) - await scene_video_chunks( - mock_js, - [ - VideoChunkMessage( - job_id="1", - chunk_index=0, - total_chunks=2, - storage_url="/fake/path.mp4", - target_resolution="480p", - ), - VideoChunkMessage( - job_id="1", - chunk_index=0, - total_chunks=2, - storage_url="/fake/path.mp4", - target_resolution="480p", - ), - ], - ) + msgs = [ + VideoChunkMessage( + job_id="1", + chunk_index=0, + total_chunks=2, + storage_url="/fake/path.mp4", + target_resolution="480p", + ), + VideoChunkMessage( + job_id="1", + chunk_index=0, + total_chunks=2, + storage_url="/fake/path.mp4", + target_resolution="480p", + ), + ] + + for msg in msgs: + await publisher( + mock_js, msg, "jobs.video.chunks", service_name="scene-detector" + ) assert mock_js.publish.call_count == 2 diff --git a/backend/shared/python/tests/unit/test_upload_video.py b/backend/shared/python/tests/unit/test_upload_video.py new file mode 100644 index 0000000..dec4668 --- /dev/null +++ b/backend/shared/python/tests/unit/test_upload_video.py @@ -0,0 +1,68 @@ +from pathlib import Path +from unittest.mock import patch, MagicMock +from shared_storage.queries import upload_video +import requests +import pytest + +fake_storage_url = "idk/idk2/chunk-001.mp4" + + +def test_raises_file_not_found(tmp_path: Path) -> None: + """Raises FileNotFoundError when the chunk path does not exist on disk""" + with pytest.raises(FileNotFoundError): + upload_video( + fake_storage_url, + "job-1", + str(tmp_path / "missing.mp4"), + service_name="scene-detector", + ) + + +@pytest.mark.parametrize("status_code", [400, 404, 500, 503]) +def test_raises_on_http_error(single_video_chunk: str, status_code: int) -> None: + """Raises HTTPError when SeaweedFS returns 4xx/5xx on upload""" + mock_response = MagicMock() + mock_response.status_code = status_code + mock_response.raise_for_status.side_effect = requests.HTTPError( + response=mock_response + ) + + with ( + patch("shared_storage.queries.requests.put", return_value=mock_response), + pytest.raises(requests.HTTPError), + ): + upload_video( + fake_storage_url, "job-1", single_video_chunk, service_name="scene-detector" + ) + + +def test_raises_on_connection_error(single_video_chunk: str) -> None: + """Raises ConnectionError when SeaweedFS is unreachable during upload""" + with ( + patch( + "shared_storage.queries.requests.put", side_effect=requests.ConnectionError + ), + pytest.raises(requests.ConnectionError), + ): + upload_video( + fake_storage_url, "job-1", single_video_chunk, service_name="scene-detector" + ) + + +def test_returns_correct_storage_url(fake_base_url: str, tmp_path: Path) -> None: + """Returns SeaweedFS URL matching {base}/{job_id}/{filename}""" + job_id = "job-abc" + chunk = tmp_path / "chunk-001.mp4" + chunk.write_bytes(b"data") + + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + + storage_url = f"{fake_base_url}/{job_id}/chunk-001.mp4" + + with patch("shared_storage.queries.requests.put", return_value=mock_response): + url = upload_video( + storage_url, job_id, str(chunk), service_name="scene-detector" + ) + + assert url == storage_url diff --git a/backend/shared/python/tests/videos/ForBiggerBlazes.mp4 b/backend/shared/python/tests/videos/ForBiggerBlazes.mp4 new file mode 100644 index 0000000..6aca024 Binary files /dev/null and b/backend/shared/python/tests/videos/ForBiggerBlazes.mp4 differ diff --git a/backend/shared/python/uv.lock b/backend/shared/python/uv.lock new file mode 100644 index 0000000..d6c9c4f --- /dev/null +++ b/backend/shared/python/uv.lock @@ -0,0 +1,580 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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" }, +] + +[[package]] +name = "nats-py" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/f8/b956c4621ba88748ed707c52e69f95b7a50c8914e750edca59a5bef84a76/nats_py-2.14.0.tar.gz", hash = "sha256:4ed02cb8e3b55c68074a063aa2687087115d805d1513297da90cb2068fb07bed", size = 120751, upload-time = "2026-02-23T22:44:58.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/39/0e87753df1072254bac190b33ed34b264f28f6aa9bea0f01b7e818071756/nats_py-2.14.0-py3-none-any.whl", hash = "sha256:4116f5d2233ce16e63c3d5538fa40a5e207f75fcf42a741773929ddf1e29d19d", size = 82259, upload-time = "2026-02-23T22:45:00.152Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyrefly" +version = "0.62.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/8874ed25781e7dd561c6d75fb4a7becf10a18d75b074f25b845cc334f781/pyrefly-0.62.0.tar.gz", hash = "sha256:da1fbe1075dc1e6c8e3134e9370b0a0e7a296061d782cca5bf83dbb8e4c10d7c", size = 5537672, upload-time = "2026-04-20T17:12:15.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ea/09bd9da7d5df294db800312fb415be2fefbaa5594178e9e49f44fa071aea/pyrefly-0.62.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d78ec4f126dee1fa76215b193b964490ce10e62a32d2787a72c51623658b803", size = 13020414, upload-time = "2026-04-20T17:11:43.617Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f0/f84afac4f220c4c8c801b779ee2ff28ad3f7731f4283c2e1b6ee9012e8c2/pyrefly-0.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2a41a34902d20756264486f9e309f22633d100261bd960feea6e858a098d985d", size = 12515659, upload-time = "2026-04-20T17:11:46.59Z" }, + { url = "https://files.pythonhosted.org/packages/40/0b/620c39cefa9ae1b25ee7a2da9d8d3c278b095649cb8435c5e01ea64f7c17/pyrefly-0.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4666c6b65aea662e5f77b64dc91c091b7ea5cede6aa66c0f4cbae26480403583", size = 36228332, upload-time = "2026-04-20T17:11:50.523Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fb/47b8b76438c12761e509a3666cd5a99d4af7f21976ba8385feb475cbfe30/pyrefly-0.62.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1aefab798f47d37c13ded791192fee9b39a6d2b12e31f38ae06a1f80c4b26e22", size = 38995741, upload-time = "2026-04-20T17:11:54.702Z" }, + { url = "https://files.pythonhosted.org/packages/55/d2/03bd17673f61147cd5609cd7d6a1455eeccc17a07a7e141ed9931b0c42c0/pyrefly-0.62.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa986b50d56740da1d7ae7c660a505143cb9d286fa98cc7e5f4a759cc6eaa5d", size = 37205321, upload-time = "2026-04-20T17:11:58.9Z" }, + { url = "https://files.pythonhosted.org/packages/75/14/20ba7b7f2d182f9b7c1e24a3041dac9b5730ae28cfe1614a2c98706650f2/pyrefly-0.62.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32e9b175805c82ffb967e4708f4910bace7e1a12736907380cc9afdbaabb0efb", size = 41786834, upload-time = "2026-04-20T17:12:03.221Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c8/5a7ba88c4fa1b5090d877f70fa1b742b921b9e7d8d3f4b6b9b1ba1820850/pyrefly-0.62.0-py3-none-win32.whl", hash = "sha256:1cd98edc20cab5bac8016c9220ee66080e39bd22e7f0e9bb3e2c4e2be1555eed", size = 12010170, upload-time = "2026-04-20T17:12:06.791Z" }, + { url = "https://files.pythonhosted.org/packages/2e/78/d8f810de010ff2ed594c630c724fd817ef430963249e9eb396ce8f785e9d/pyrefly-0.62.0-py3-none-win_amd64.whl", hash = "sha256:6994f8ee7d6720325ee52207fbdaca98a799a1efe462bb5ba90c47160f7f3e6e", size = 12861816, upload-time = "2026-04-20T17:12:09.689Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a9/ac824ef6a3f50b7c0ec5974471f8f2cb205cd1edd53a5abbcf7ba37feb5d/pyrefly-0.62.0-py3-none-win_arm64.whl", hash = "sha256:362a5d47a5ac5aaa5258091e878a1759ff8b687d8cf462af1c516144f7b0108a", size = 12352977, upload-time = "2026-04-20T17:12:12.736Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[package]] +name = "shared-python" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "nats-py" }, + { name = "pydantic-settings" }, + { name = "pytest-asyncio" }, + { name = "structlog" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyrefly" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "testcontainers", extra = ["nats"] }, +] + +[package.metadata] +requires-dist = [ + { name = "nats-py", specifier = ">=2.14.0" }, + { name = "pydantic-settings", specifier = ">=2.14.0" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "structlog", specifier = ">=25.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyrefly", specifier = ">=0.62.0" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.15.11" }, + { name = "testcontainers", extras = ["nats"], specifier = ">=4.14.2" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + +[package.optional-dependencies] +nats = [ + { name = "nats-py" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] diff --git a/backend/transcoder-worker/cmd/main.go b/backend/transcoder-worker/cmd/main.go index 38d2c4a..5882ea1 100644 --- a/backend/transcoder-worker/cmd/main.go +++ b/backend/transcoder-worker/cmd/main.go @@ -8,13 +8,13 @@ import ( "os/signal" "shared/kv" "shared/middleware" + "shared/service" "syscall" + shandler "shared/handler" "shared/storage" "transcoder-worker/internal/handler" - "github.com/joho/godotenv" - "github.com/kelseyhightower/envconfig" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) @@ -30,7 +30,7 @@ type Config struct { } func main() { - cfg, err := loadConfig() + cfg, err := service.LoadConfig[Config]() if err != nil { log.Fatalf("failed to load config values: %v", err) } @@ -85,33 +85,18 @@ func runProcessing( ) error { logger.Debug("starting service") - server := handler.StartHttpServer(logger, httpPort) + server := shandler.StartHealthHttpServer(logger, httpPort) consCtx, err := handler.ConsumeVideoChunk(baseStorageURL, js, processedKV, jobStatusKV, logger) if err != nil { - handler.ShutdownHttpServer(server, logger) + shandler.ShutdownHttpServer(server, logger) return fmt.Errorf("failed to start consumer: %w", err) } <-quit - handler.ShutdownHttpServer(server, logger) + shandler.ShutdownHttpServer(server, logger) consCtx.Stop() // stop recieving new msgs from jetstream return nc.Drain() } - -func loadConfig() (*Config, error) { - err := godotenv.Load("../.env") - if err != nil { - log.Println("missing .env file") - } - var cfg Config - - err = envconfig.Process("", &cfg) - if err != nil { - return nil, err - } - - return &cfg, nil -} diff --git a/backend/transcoder-worker/cmd/main_integration_test.go b/backend/transcoder-worker/cmd/main_integration_test.go index 7ef2ad0..1423231 100644 --- a/backend/transcoder-worker/cmd/main_integration_test.go +++ b/backend/transcoder-worker/cmd/main_integration_test.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "shared/handler" + stest "shared/test" "syscall" "testing" "time" @@ -25,7 +26,7 @@ import ( var sharedFilerURL string func TestMain(m *testing.M) { - filerURL, cleanup := test.StartSeaweedFSFiler() + filerURL, cleanup := stest.StartSeaweedFSFiler() sharedFilerURL = filerURL code := m.Run() @@ -36,7 +37,7 @@ func TestMain(m *testing.M) { func TestRunProcessingI(t *testing.T) { t.Run("quit signal exits cleanly", func(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobStatusKV := test.SetupJobStatusKV(t, js) quit := make(chan os.Signal, 1) @@ -62,7 +63,7 @@ func TestRunProcessingI(t *testing.T) { t.Skip("ffmpeg not available") } - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobID := "job-full-flow" diff --git a/backend/transcoder-worker/cmd/main_unit_test.go b/backend/transcoder-worker/cmd/main_unit_test.go index abbb457..7ba7a22 100644 --- a/backend/transcoder-worker/cmd/main_unit_test.go +++ b/backend/transcoder-worker/cmd/main_unit_test.go @@ -5,7 +5,6 @@ package main import ( "net" "os" - "path/filepath" "strconv" "testing" "time" @@ -106,40 +105,6 @@ func TestRunProcessing(t *testing.T) { }) } -func TestLoadConfig(t *testing.T) { - t.Run("missing env file doesnt return error", func(t *testing.T) { - if _, err := os.Stat(filepath.Join("..", ".env")); err == nil { - t.Skip(".env already exists") - } - - _, err := loadConfig() - - assert.NoError(t, err) - }) - - t.Run("reads all values from env file", func(t *testing.T) { - writeEnvFile(t, "NATS_URL=nats://test:9999\nPROD_MODE=true\nBASE_STORAGE_URL=http://storage:8888\n") - - cfg, err := loadConfig() - - require.NoError(t, err) - assert.Equal(t, "nats://test:9999", cfg.NatsURL) - assert.True(t, cfg.ProdMode) - assert.Equal(t, "http://storage:8888", cfg.BaseStorageURL) - }) - - t.Run("empty env file uses struct defaults", func(t *testing.T) { - writeEnvFile(t, "") - - cfg, err := loadConfig() - - require.NoError(t, err) - assert.Equal(t, "nats://localhost:4222", cfg.NatsURL) - assert.False(t, cfg.ProdMode) - assert.Equal(t, "http://localhost:8888", cfg.BaseStorageURL) - }) -} - func TestMainFunc(t *testing.T) { t.Run("exits on storage health check failure", func(t *testing.T) { code := patchOsExit(t) diff --git a/backend/transcoder-worker/cmd/makefile b/backend/transcoder-worker/cmd/makefile index 2165c96..b683267 100644 --- a/backend/transcoder-worker/cmd/makefile +++ b/backend/transcoder-worker/cmd/makefile @@ -11,7 +11,7 @@ lint: golangci-lint run ../internal/test/... coverage: - go test -tags=unit,integration ${PKGS} -v -coverprofile=coverage.out -covermode=atomic + go test -tags=unit,integration ${PKGS} -v -p 1 -coverprofile=coverage.out -covermode=atomic integration: go test -tags integration ${PKGS} diff --git a/backend/transcoder-worker/go.mod b/backend/transcoder-worker/go.mod index 730d627..d8130fe 100644 --- a/backend/transcoder-worker/go.mod +++ b/backend/transcoder-worker/go.mod @@ -3,8 +3,6 @@ module transcoder-worker go 1.26.2 require ( - github.com/joho/godotenv v1.5.1 - github.com/kelseyhightower/envconfig v1.4.0 github.com/nats-io/nats.go v1.51.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 diff --git a/backend/transcoder-worker/go.sum b/backend/transcoder-worker/go.sum index ba1a9ea..71abf55 100644 --- a/backend/transcoder-worker/go.sum +++ b/backend/transcoder-worker/go.sum @@ -46,10 +46,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= -github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/backend/transcoder-worker/internal/handler/http_unit_test.go b/backend/transcoder-worker/internal/handler/http_unit_test.go deleted file mode 100644 index a3d49a1..0000000 --- a/backend/transcoder-worker/internal/handler/http_unit_test.go +++ /dev/null @@ -1,46 +0,0 @@ -//go:build unit - -package handler_test - -import ( - "encoding/json" - "net/http" - "testing" - "time" - "transcoder-worker/internal/handler" - "transcoder-worker/internal/test" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// health endpoint returns healthy status -func TestStartHttpServer(t *testing.T) { - port := test.FreePort(t) - server := handler.StartHttpServer(test.SilentLogger(), port) - t.Cleanup(func() { handler.ShutdownHttpServer(server, test.SilentLogger()) }) - - time.Sleep(50 * time.Millisecond) - - resp, err := http.Get("http://localhost:" + port + "/health") - require.NoError(t, err) - defer resp.Body.Close() - - assert.Equal(t, http.StatusOK, resp.StatusCode) - - var body map[string]string - require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) - assert.Equal(t, "Healthy", body["status"]) -} - -// server stops accepting connections after shutdown -func TestShutdownHttpServer(t *testing.T) { - port := test.FreePort(t) - server := handler.StartHttpServer(test.SilentLogger(), port) - time.Sleep(50 * time.Millisecond) - - handler.ShutdownHttpServer(server, test.SilentLogger()) - - _, err := http.Get("http://localhost:" + port + "/health") - assert.Error(t, err, "server should no longer accept connections after shutdown") -} diff --git a/backend/transcoder-worker/internal/handler/subscriber.go b/backend/transcoder-worker/internal/handler/subscriber.go index 1c2c2f2..926fa95 100644 --- a/backend/transcoder-worker/internal/handler/subscriber.go +++ b/backend/transcoder-worker/internal/handler/subscriber.go @@ -1,7 +1,6 @@ package handler import ( - "encoding/json" "fmt" "log/slog" "os" @@ -29,16 +28,8 @@ func ConsumeVideoChunk( } consCtx, err := cons.Consume(func(msg jetstream.Msg) { - var payload service.VideoChunkMessage - - err := json.Unmarshal(msg.Data(), &payload) - if err != nil { - logger.Error("failed to unmarshal msg from jetstream", "err", err) - err := msg.Nak() - if err != nil { - logger.Error("error naking msg", "err", err) - return - } + payload, ok := handler.UnmarshalJetstreamMsg[service.VideoChunkMessage](msg, logger) + if !ok { return } @@ -50,11 +41,7 @@ func ConsumeVideoChunk( if exists { logger.Debug("message already processed, skipping") - err := msg.Ack() - if err != nil { - logger.Error("error acking msg", "err", err) - return - } + kv.AckWithErrHandling(logger, msg) return } @@ -68,22 +55,14 @@ func ConsumeVideoChunk( filePath, err := storage.GetVideoChunk(payload.StorageURL, fileName) if err != nil { logger.Error("error fetching unprocessed video chunk", "job_id", payload.JobID, "err", err) - err := msg.Nak() - if err != nil { - logger.Error("error naking msg for get unprocessed video chunk", "err", err) - return - } + kv.NakWithErrHandling(logger, msg) return } outputPath, err := service.TranscodeVideo(filePath, payload.TargetResolution, payload.JobID, logger) if err != nil { logger.Error("error transcoding chunk", "job_id", payload.JobID, "chunk_index", payload.ChunkIndex, "err", err) - err := msg.Nak() - if err != nil { - logger.Error("error naking msg", "err", err) - return - } + kv.NakWithErrHandling(logger, msg) return } @@ -98,11 +77,7 @@ func ConsumeVideoChunk( "file_path", outputPath, "err", err, ) - err := msg.Nak() - if err != nil { - logger.Error("error naking msg", "err", err) - return - } + kv.NakWithErrHandling(logger, msg) return } @@ -116,11 +91,7 @@ func ConsumeVideoChunk( }, pubSubject) if err != nil { logger.Error("failed to pub chunk complete msg", "job_id", payload.JobID, "chunk_index", payload.ChunkIndex, "err", err) - err := msg.Nak() - if err != nil { - logger.Error("error naking msg", "err", err) - return - } + kv.NakWithErrHandling(logger, msg) return } diff --git a/backend/transcoder-worker/internal/handler/subscriber_integration_test.go b/backend/transcoder-worker/internal/handler/subscriber_integration_test.go index 5b7f1df..d86eff7 100644 --- a/backend/transcoder-worker/internal/handler/subscriber_integration_test.go +++ b/backend/transcoder-worker/internal/handler/subscriber_integration_test.go @@ -9,6 +9,7 @@ import ( "fmt" "os" shandler "shared/handler" + stest "shared/test" "testing" "time" "transcoder-worker/internal/service" @@ -24,7 +25,7 @@ import ( var sharedFilerURL string func TestMain(m *testing.M) { - filerURL, cleanup := test.StartSeaweedFSFiler() + filerURL, cleanup := stest.StartSeaweedFSFiler() sharedFilerURL = filerURL code := m.Run() @@ -36,7 +37,7 @@ func TestMain(m *testing.M) { func TestConsumeVideoChunk(t *testing.T) { t.Run("consumer is created with correct config", func(t *testing.T) { ctx := context.Background() - js, _ := test.SetupNats(t) + js, _ := stest.SetupNats(t) kv := test.SetupKV(t, js) jobStatusKV := test.SetupJobStatusKV(t, js) @@ -59,7 +60,7 @@ func TestConsumeVideoChunk(t *testing.T) { }) t.Run("invalid JSON does not publish downstream", func(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobStatusKV := test.SetupJobStatusKV(t, js) @@ -84,7 +85,7 @@ func TestConsumeVideoChunk(t *testing.T) { }) t.Run("valid message publishes chunk complete and acks", func(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobID := "job-full-flow" @@ -152,7 +153,7 @@ func TestConsumeVideoChunkNaksOnError(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - js, _ := test.SetupNats(t) + js, _ := stest.SetupNats(t) kv := test.SetupKV(t, js) jobID := "job-nak-" + tc.fileName t.Cleanup(func() { @@ -228,7 +229,7 @@ func TestConsumeVideoChunkPublishFails(t *testing.T) { func TestConsumeVideoChunkCleanup(t *testing.T) { seedAndConsume := func(t *testing.T, jobID string) (jetstream.JetStream, <-chan struct{}) { t.Helper() - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) videoContent, err := os.ReadFile("../test/test_video.mp4") @@ -293,7 +294,7 @@ func TestConsumeVideoChunkCleanup(t *testing.T) { func TestConsumeVideoChunkIdempotency(t *testing.T) { t.Run("already processed chunk is acked and skipped", func(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobID := "job-idempotency-skip" @@ -324,7 +325,7 @@ func TestConsumeVideoChunkIdempotency(t *testing.T) { }) t.Run("kv entry is written after successful processing", func(t *testing.T) { - js, _ := test.SetupNats(t) + js, _ := stest.SetupNats(t) kv := test.SetupKV(t, js) jobID := "job-idempotency-write" @@ -354,7 +355,7 @@ func TestConsumeVideoChunkIdempotency(t *testing.T) { }) t.Run("kv entry is not written when processing fails", func(t *testing.T) { - js, _ := test.SetupNats(t) + js, _ := stest.SetupNats(t) kv := test.SetupKV(t, js) jobID := "job-idempotency-no-write-on-fail" diff --git a/backend/transcoder-worker/internal/handler/subscriber_unit_test.go b/backend/transcoder-worker/internal/handler/subscriber_unit_test.go index 6ab79b4..1fe6674 100644 --- a/backend/transcoder-worker/internal/handler/subscriber_unit_test.go +++ b/backend/transcoder-worker/internal/handler/subscriber_unit_test.go @@ -38,43 +38,15 @@ func TestConsumeFailReturnError(t *testing.T) { assert.ErrorIs(t, err, consumeErr) } -func TestAckAndNacking(t *testing.T) { - t.Run("invalid JSON naks and does not ack", func(t *testing.T) { - msg := &test.MockMsg{Payload: []byte("not valid json")} - consumer := &test.MockConsumerWithMsg{Msg: msg} - js := &test.MockJS{JStream: &test.MockStream{Cons: consumer}} - - consCtx, err := handler.ConsumeVideoChunk("http://storage", js, &test.MockKV{}, &test.MockKV{}, test.SilentLogger()) - - require.NoError(t, err) - assert.NotNil(t, consCtx) - assert.True(t, msg.NakCalled) - assert.False(t, msg.AckCalled) - }) - - t.Run("invalid JSON with nak error logs and returns", func(t *testing.T) { - nakErr := errors.New("nak failed") - msg := &test.MockMsg{Payload: []byte("not valid json"), NakErr: nakErr} - consumer := &test.MockConsumerWithMsg{Msg: msg} - js := &test.MockJS{JStream: &test.MockStream{Cons: consumer}} - - consCtx, err := handler.ConsumeVideoChunk("http://storage", js, &test.MockKV{}, &test.MockKV{}, test.SilentLogger()) +func TestFetchFailureNaks(t *testing.T) { + msg := &test.MockMsg{Payload: validPayload(t, "job-1")} + consumer := &test.MockConsumerWithMsg{Msg: msg} + js := &test.MockJS{JStream: &test.MockStream{Cons: consumer}} - require.NoError(t, err) - assert.NotNil(t, consCtx) - assert.True(t, msg.NakCalled) - }) - - t.Run("fetch failure naks", func(t *testing.T) { - msg := &test.MockMsg{Payload: validPayload(t, "job-1")} - consumer := &test.MockConsumerWithMsg{Msg: msg} - js := &test.MockJS{JStream: &test.MockStream{Cons: consumer}} - - _, err := handler.ConsumeVideoChunk("http://storage", js, &test.MockKV{}, &test.MockKV{}, test.SilentLogger()) + _, err := handler.ConsumeVideoChunk("http://storage", js, &test.MockKV{}, &test.MockKV{}, test.SilentLogger()) - require.NoError(t, err) - assert.True(t, msg.NakCalled) - }) + require.NoError(t, err) + assert.True(t, msg.NakCalled) } func TestIdempotency(t *testing.T) { diff --git a/backend/transcoder-worker/internal/test/nats_fixtures.go b/backend/transcoder-worker/internal/test/nats_fixtures.go index 6f3791a..f7c3591 100644 --- a/backend/transcoder-worker/internal/test/nats_fixtures.go +++ b/backend/transcoder-worker/internal/test/nats_fixtures.go @@ -5,48 +5,14 @@ package test import ( "context" "testing" - "time" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "github.com/stretchr/testify/require" tc "github.com/testcontainers/testcontainers-go" - natstc "github.com/testcontainers/testcontainers-go/modules/nats" "github.com/testcontainers/testcontainers-go/wait" ) -// fixture for setting up nats container for testing -func SetupNats(t *testing.T) (jetstream.JetStream, *nats.Conn) { - t.Helper() - ctx := context.Background() - - container, err := natstc.Run(ctx, "nats:2.10-alpine") - require.NoError(t, err) - t.Cleanup(func() { _ = container.Terminate(ctx) }) - - url, err := container.ConnectionString(ctx) - require.NoError(t, err) - - nc, err := nats.Connect(url, - nats.RetryOnFailedConnect(true), - nats.MaxReconnects(10), - nats.ReconnectWait(200*time.Millisecond), - ) - require.NoError(t, err) - t.Cleanup(nc.Close) - - js, err := jetstream.New(nc) - require.NoError(t, err) - - _, err = js.CreateStream(ctx, jetstream.StreamConfig{ - Name: "jobs", - Subjects: []string{"jobs.>"}, - }) - require.NoError(t, err) - - return js, nc -} - // starts a plain NATS container without JetStream enabled and returns the connection. func SetupNatsNoJetStream(t *testing.T) *nats.Conn { t.Helper() diff --git a/backend/transcoder-worker/internal/test/storage_helpers.go b/backend/transcoder-worker/internal/test/storage_helpers.go index 7dba086..89ca3cd 100644 --- a/backend/transcoder-worker/internal/test/storage_helpers.go +++ b/backend/transcoder-worker/internal/test/storage_helpers.go @@ -4,59 +4,13 @@ package test import ( "bytes" - "context" "fmt" "net/http" - "os" "testing" "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" ) -// StartSeaweedFSFiler starts a SeaweedFS filer container and returns the filer -// URL and a cleanup function. Use this when t.Cleanup is not available (e.g. TestMain). -func StartSeaweedFSFiler() (string, func()) { - ctx := context.Background() - - req := testcontainers.ContainerRequest{ - Image: "chrislusf/seaweedfs", - Cmd: []string{"server", "-dir=/data", "-master.port=9333", "-volume.port=8080", "-filer"}, - ExposedPorts: []string{"9333/tcp", "8888/tcp"}, - WaitingFor: wait.ForAll( - wait.ForHTTP("/dir/status").WithPort("9333/tcp").WithStatusCodeMatcher(func(status int) bool { return status < 500 }), - wait.ForHTTP("/").WithPort("8888/tcp").WithStatusCodeMatcher(func(status int) bool { return status < 500 }), - ), - } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - panic("failed to start SeaweedFS container: " + err.Error()) - } - - endpoint, err := container.PortEndpoint(ctx, "8888/tcp", "http") - if err != nil { - panic("failed to get SeaweedFS filer endpoint: " + err.Error()) - } - - return endpoint, func() { _ = container.Terminate(ctx) } -} - -const testVideoPath = "../test/test_video.mp4" - -// helper to open the test video -func OpenTestVideo(t *testing.T) *os.File { - t.Helper() - f, err := os.Open(testVideoPath) - require.NoError(t, err) - t.Cleanup(func() { f.Close() }) - return f -} - func SeedUnprocessedVideo(t *testing.T, filerURL, jobID, fileName string, content []byte) string { t.Helper() url := fmt.Sprintf("%s/%s/%s", filerURL, jobID, fileName) @@ -69,15 +23,3 @@ func SeedUnprocessedVideo(t *testing.T, filerURL, jobID, fileName string, conten require.Less(t, resp.StatusCode, 400) return url } - -func SeedProcessedVideo(t *testing.T, filerURL, jobID, fileName string, content []byte) { - t.Helper() - url := fmt.Sprintf("%s/%s/processed/%s", filerURL, jobID, fileName) - req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(content)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/octet-stream") - resp, err := http.DefaultClient.Do(req) - require.NoError(t, err) - resp.Body.Close() - require.Less(t, resp.StatusCode, 400) -} diff --git a/backend/video-recombiner/cmd/main.go b/backend/video-recombiner/cmd/main.go index 2c1fc44..3a4c8f5 100644 --- a/backend/video-recombiner/cmd/main.go +++ b/backend/video-recombiner/cmd/main.go @@ -6,14 +6,14 @@ import ( "log/slog" "os" "os/signal" + shandler "shared/handler" "shared/kv" "shared/middleware" + "shared/service" "shared/storage" "syscall" "video-recombiner/internal/handler" - "github.com/joho/godotenv" - "github.com/kelseyhightower/envconfig" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) @@ -28,7 +28,7 @@ type Config struct { } func main() { - cfg, err := loadConfig() + cfg, err := service.LoadConfig[Config]() if err != nil { log.Fatalf("failed to load config values: %v", err) } @@ -82,33 +82,18 @@ func runCombiner( ) error { logger.Debug("starting service...") - server := handler.StartHttpServer(logger, httpPort) + server := shandler.StartHealthHttpServer(logger, httpPort) consCtx, err := handler.RecombineVideo(js, msgRecievedKV, jobStatusKV, logger, baseStorageURL) if err != nil { - handler.ShutdownHttpServer(server, logger) + shandler.ShutdownHttpServer(server, logger) return fmt.Errorf("failed to start subscriber/publisher: %w", err) } <-quit - handler.ShutdownHttpServer(server, logger) + shandler.ShutdownHttpServer(server, logger) consCtx.Stop() return nc.Drain() } - -func loadConfig() (*Config, error) { - err := godotenv.Load("../.env") - if err != nil { - log.Println("missing .env file") - } - var cfg Config - - err = envconfig.Process("", &cfg) - if err != nil { - return nil, err - } - - return &cfg, nil -} diff --git a/backend/video-recombiner/cmd/main_integration_test.go b/backend/video-recombiner/cmd/main_integration_test.go index 7bb714b..a818fc2 100644 --- a/backend/video-recombiner/cmd/main_integration_test.go +++ b/backend/video-recombiner/cmd/main_integration_test.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "shared/handler" + stest "shared/test" "syscall" "testing" "time" @@ -24,7 +25,7 @@ import ( var sharedFilerURL string func TestMain(m *testing.M) { - filerURL, cleanup := test.StartSeaweedFSFiler() + filerURL, cleanup := stest.StartSeaweedFSFiler() sharedFilerURL = filerURL code := m.Run() @@ -35,7 +36,7 @@ func TestMain(m *testing.M) { func TestRunCombinerI(t *testing.T) { t.Run("quit signal exits cleanly", func(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobStatusKV := test.SetupJobStatusKV(t, js) quit := make(chan os.Signal, 1) @@ -57,20 +58,9 @@ func TestRunCombinerI(t *testing.T) { }) t.Run("no stream returns error", func(t *testing.T) { - ctx := context.Background() - - container, err := natstc.Run(ctx, "nats:2.10-alpine") - require.NoError(t, err) - t.Cleanup(func() { _ = container.Terminate(ctx) }) + js, nc := stest.SetupNats(t) - url, err := container.ConnectionString(ctx) - require.NoError(t, err) - - nc, err := nats.Connect(url) - require.NoError(t, err) - t.Cleanup(nc.Close) - - js, err := jetstream.New(nc) + err := js.DeleteStream(context.Background(), "jobs") require.NoError(t, err) quit := make(chan os.Signal, 1) @@ -85,7 +75,7 @@ func TestRunCombinerI(t *testing.T) { t.Skip("ffmpeg not available") } - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobStatusKV := test.SetupJobStatusKV(t, js) diff --git a/backend/video-recombiner/cmd/main_unit_test.go b/backend/video-recombiner/cmd/main_unit_test.go index b96f172..e855994 100644 --- a/backend/video-recombiner/cmd/main_unit_test.go +++ b/backend/video-recombiner/cmd/main_unit_test.go @@ -5,7 +5,6 @@ package main import ( "net" "os" - "path/filepath" "testing" "time" "video-recombiner/internal/test" @@ -96,40 +95,6 @@ func TestRunCombiner(t *testing.T) { }) } -func TestLoadConfig(t *testing.T) { - t.Run("missing env file doesnt return error", func(t *testing.T) { - if _, err := os.Stat(filepath.Join("..", ".env")); err == nil { - t.Skip(".env already exists") - } - - _, err := loadConfig() - - assert.NoError(t, err) - }) - - t.Run("reads all values from env file", func(t *testing.T) { - writeEnvFile(t, "NATS_URL=nats://test:9999\nPROD_MODE=true\nBASE_STORAGE_URL=http://localhost:9333\nHTTP_PORT=9090\n") - - cfg, err := loadConfig() - - require.NoError(t, err) - assert.Equal(t, "nats://test:9999", cfg.NatsURL) - assert.True(t, cfg.ProdMode) - assert.Equal(t, "http://localhost:9333", cfg.BaseStorageURL) - }) - - t.Run("empty env file uses struct defaults", func(t *testing.T) { - writeEnvFile(t, "") - - cfg, err := loadConfig() - - require.NoError(t, err) - assert.Equal(t, "nats://localhost:4222", cfg.NatsURL) - assert.False(t, cfg.ProdMode) - assert.Equal(t, "http://localhost:8888", cfg.BaseStorageURL) - }) -} - func TestMainFunc(t *testing.T) { t.Run("exits on storage health check failure", func(t *testing.T) { code := patchExit(t) diff --git a/backend/video-recombiner/go.mod b/backend/video-recombiner/go.mod index 8e0cf8b..d955a6f 100644 --- a/backend/video-recombiner/go.mod +++ b/backend/video-recombiner/go.mod @@ -3,8 +3,6 @@ module video-recombiner go 1.26.2 require ( - github.com/joho/godotenv v1.5.1 - github.com/kelseyhightower/envconfig v1.4.0 github.com/nats-io/nats.go v1.51.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 diff --git a/backend/video-recombiner/go.sum b/backend/video-recombiner/go.sum index ba1a9ea..71abf55 100644 --- a/backend/video-recombiner/go.sum +++ b/backend/video-recombiner/go.sum @@ -46,10 +46,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= -github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/backend/video-recombiner/internal/handler/http.go b/backend/video-recombiner/internal/handler/http.go deleted file mode 100644 index aade3ff..0000000 --- a/backend/video-recombiner/internal/handler/http.go +++ /dev/null @@ -1,54 +0,0 @@ -package handler - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "os" - "time" -) - -var osExit = os.Exit - -// starts the http server with /health endpoint -func StartHttpServer(logger *slog.Logger, httpPort string) *http.Server { - router := http.NewServeMux() - - router.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - err := json.NewEncoder(w).Encode(map[string]string{"status": "Healthy"}) - if err != nil { - logger.Error("failed to encode health status msg", "err", err) - } - }) - - server := &http.Server{ - Addr: ":" + httpPort, - Handler: router, - } - - go func() { - fmt.Printf("server running on http://localhost:%s\n", httpPort) - - err := server.ListenAndServe() - if err != nil && err != http.ErrServerClosed { - logger.Error("http server error", "err", err) - osExit(1) - } - }() - - return server -} - -func ShutdownHttpServer(server *http.Server, logger *slog.Logger) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err := server.Shutdown(ctx) - if err != nil { - logger.Error("error shutting down http server", "err", err) - } -} diff --git a/backend/video-recombiner/internal/handler/subscriber.go b/backend/video-recombiner/internal/handler/subscriber.go index 24c195c..a935fa8 100644 --- a/backend/video-recombiner/internal/handler/subscriber.go +++ b/backend/video-recombiner/internal/handler/subscriber.go @@ -1,7 +1,6 @@ package handler import ( - "encoding/json" "fmt" "log/slog" "path/filepath" @@ -27,14 +26,8 @@ func RecombineVideo( tracker := service.NewJobTracker() consCtx, err := cons.Consume(func(msg jetstream.Msg) { - var payload handler.ChunkCompleteMessage - - err := json.Unmarshal(msg.Data(), &payload) - if err != nil { - logger.Error("failed to unmarshal msg from jetstream", "err", err) - if err := msg.Nak(); err != nil { - logger.Error("error naking msg", "err", err) - } + payload, ok := handler.UnmarshalJetstreamMsg[handler.ChunkCompleteMessage](msg, logger) + if !ok { return } @@ -46,11 +39,7 @@ func RecombineVideo( if recieved { logger.Debug("message already recieved, skipping") - err := msg.Ack() - if err != nil { - logger.Error("error acking msg", "err", err) - return - } + kv.AckWithErrHandling(logger, msg) return } diff --git a/backend/video-recombiner/internal/handler/subscriber_integration_test.go b/backend/video-recombiner/internal/handler/subscriber_integration_test.go index 48916dc..9aee096 100644 --- a/backend/video-recombiner/internal/handler/subscriber_integration_test.go +++ b/backend/video-recombiner/internal/handler/subscriber_integration_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" shandler "shared/handler" + stest "shared/test" "testing" "time" "video-recombiner/internal/handler" @@ -22,7 +23,7 @@ import ( var sharedFilerURL string func TestMain(m *testing.M) { - filerURL, cleanup := test.StartSeaweedFSFiler() + filerURL, cleanup := stest.StartSeaweedFSFiler() sharedFilerURL = filerURL code := m.Run() @@ -34,7 +35,7 @@ func TestMain(m *testing.M) { // it should create consumer with correct config func TestReturnCorrectConfig(t *testing.T) { ctx := context.Background() - js, _ := test.SetupNats(t) + js, _ := stest.SetupNats(t) kv := test.SetupKV(t, js) jobStatusKV := test.SetupJobStatusKV(t, js) @@ -61,7 +62,7 @@ func TestReturnCorrectConfig(t *testing.T) { func TestMessageHandlingI(t *testing.T) { t.Run("invalid JSON does not publish downstream", func(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobStatusKV := test.SetupJobStatusKV(t, js) @@ -86,7 +87,7 @@ func TestMessageHandlingI(t *testing.T) { }) t.Run("partial chunk does not publish downstream", func(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobStatusKV := test.SetupJobStatusKV(t, js) @@ -119,10 +120,10 @@ func TestMessageHandlingI(t *testing.T) { }) t.Run("all chunks received triggers combine", func(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) - videoFile := test.OpenTestVideo(t) + videoFile := stest.OpenTestVideo(t) videoData, err := os.ReadFile(videoFile.Name()) require.NoError(t, err) @@ -165,7 +166,7 @@ func TestMessageHandlingI(t *testing.T) { func TestRecombineVideoIdempotency(t *testing.T) { t.Run("already received chunk is acked and skipped", func(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) jobID := "job-idempotency-skip" @@ -204,7 +205,7 @@ func TestRecombineVideoIdempotency(t *testing.T) { }) t.Run("kv entry is written after chunk is acked", func(t *testing.T) { - js, _ := test.SetupNats(t) + js, _ := stest.SetupNats(t) kv := test.SetupKV(t, js) jobID := "job-idempotency-write" diff --git a/backend/video-recombiner/internal/test/nats_fixtures.go b/backend/video-recombiner/internal/test/nats_fixtures.go index 94ea9a3..63b62ea 100644 --- a/backend/video-recombiner/internal/test/nats_fixtures.go +++ b/backend/video-recombiner/internal/test/nats_fixtures.go @@ -5,48 +5,14 @@ package test import ( "context" "testing" - "time" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "github.com/stretchr/testify/require" tc "github.com/testcontainers/testcontainers-go" - natstc "github.com/testcontainers/testcontainers-go/modules/nats" "github.com/testcontainers/testcontainers-go/wait" ) -// fixture for setting up nats container for testing -func SetupNats(t *testing.T) (jetstream.JetStream, *nats.Conn) { - t.Helper() - ctx := context.Background() - - container, err := natstc.Run(ctx, "nats:2.10-alpine") - require.NoError(t, err) - t.Cleanup(func() { _ = container.Terminate(ctx) }) - - url, err := container.ConnectionString(ctx) - require.NoError(t, err) - - nc, err := nats.Connect(url, - nats.RetryOnFailedConnect(true), - nats.MaxReconnects(10), - nats.ReconnectWait(200*time.Millisecond), - ) - require.NoError(t, err) - t.Cleanup(nc.Close) - - js, err := jetstream.New(nc) - require.NoError(t, err) - - _, err = js.CreateStream(ctx, jetstream.StreamConfig{ - Name: "jobs", - Subjects: []string{"jobs.>"}, - }) - require.NoError(t, err) - - return js, nc -} - // starts a plain NATS container without JetStream enabled and returns the connection. func SetupNatsNoJetStream(t *testing.T) *nats.Conn { t.Helper() diff --git a/backend/video-recombiner/internal/test/storage_helpers.go b/backend/video-recombiner/internal/test/storage_helpers.go index dd51f93..71dfe61 100644 --- a/backend/video-recombiner/internal/test/storage_helpers.go +++ b/backend/video-recombiner/internal/test/storage_helpers.go @@ -4,59 +4,13 @@ package test import ( "bytes" - "context" "fmt" "net/http" - "os" "testing" "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" ) -// StartSeaweedFSFiler starts a SeaweedFS filer container and returns the filer -// URL and a cleanup function. Use this when t.Cleanup is not available (e.g. TestMain). -func StartSeaweedFSFiler() (string, func()) { - ctx := context.Background() - - req := testcontainers.ContainerRequest{ - Image: "chrislusf/seaweedfs", - Cmd: []string{"server", "-dir=/data", "-master.port=9333", "-volume.port=8080", "-filer"}, - ExposedPorts: []string{"9333/tcp", "8888/tcp"}, - WaitingFor: wait.ForAll( - wait.ForHTTP("/dir/status").WithPort("9333/tcp").WithStatusCodeMatcher(func(status int) bool { return status < 500 }), - wait.ForHTTP("/").WithPort("8888/tcp").WithStatusCodeMatcher(func(status int) bool { return status < 500 }), - ), - } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - panic("failed to start SeaweedFS container: " + err.Error()) - } - - endpoint, err := container.PortEndpoint(ctx, "8888/tcp", "http") - if err != nil { - panic("failed to get SeaweedFS filer endpoint: " + err.Error()) - } - - return endpoint, func() { _ = container.Terminate(ctx) } -} - -const testVideoPath = "../test/testvideo.mp4" - -// helper to open the test video -func OpenTestVideo(t *testing.T) *os.File { - t.Helper() - f, err := os.Open(testVideoPath) - require.NoError(t, err) - t.Cleanup(func() { f.Close() }) - return f -} - func SeedProcessedVideo(t *testing.T, filerURL, jobID, fileName string, content []byte) { t.Helper() url := fmt.Sprintf("%s/%s/%s/processed", filerURL, jobID, fileName) diff --git a/backend/video-status/cmd/main.go b/backend/video-status/cmd/main.go index 397e8cd..3c32547 100644 --- a/backend/video-status/cmd/main.go +++ b/backend/video-status/cmd/main.go @@ -1,19 +1,18 @@ package main import ( - "context" "fmt" "log" "log/slog" "net/http" "os" "os/signal" + shandler "shared/handler" "shared/middleware" + "shared/service" "syscall" - "time" "video-status/internal/handler" - "github.com/kelseyhightower/envconfig" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) @@ -30,7 +29,7 @@ type Config struct { var osExit = os.Exit func main() { - cfg, err := loadConfig() + cfg, err := service.LoadConfig[Config]() if err != nil { log.Fatalf("failed to load config values: %v", err) } @@ -78,13 +77,7 @@ func main() { } jobCompleteSub.Stop() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - err = server.Shutdown(ctx) - if err != nil { - logger.Error("http server shutdown error", "err", err) - } + shandler.ShutdownHttpServer(server, logger) err = nc.Drain() if err != nil { @@ -124,14 +117,3 @@ func startHttpApi(logger *slog.Logger, jobStatusKV jetstream.KeyValue, cfg *Conf return server } - -func loadConfig() (*Config, error) { - var cfg Config - - err := envconfig.Process("", &cfg) - if err != nil { - return nil, err - } - - return &cfg, nil -} diff --git a/backend/video-status/cmd/main_unit_test.go b/backend/video-status/cmd/main_unit_test.go index fb557d2..1316862 100644 --- a/backend/video-status/cmd/main_unit_test.go +++ b/backend/video-status/cmd/main_unit_test.go @@ -7,7 +7,6 @@ import ( "net" "net/http" "net/http/httptest" - "os" "testing" "time" "video-status/internal/test" @@ -16,72 +15,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestLoadConfig(t *testing.T) { - t.Run("defaults when no env vars set", func(t *testing.T) { - for _, key := range []string{"NATS_URL", "PROD_MODE", "HTTP_PORT"} { - orig, existed := os.LookupEnv(key) - os.Unsetenv(key) - if existed { - t.Cleanup(func() { os.Setenv(key, orig) }) - } - } - - cfg, err := loadConfig() - - require.NoError(t, err) - assert.Equal(t, "nats://localhost:4222", cfg.NatsURL) - assert.Equal(t, false, cfg.ProdMode) - assert.Equal(t, "8085", cfg.HTTPPort) - }) - - t.Run("env var overrides", func(t *testing.T) { - tests := []struct { - name string - envKey string - envVal string - check func(t *testing.T, cfg *Config) - }{ - { - name: "NATS_URL overrides default", - envKey: "NATS_URL", - envVal: "nats://remote:4222", - check: func(t *testing.T, cfg *Config) { assert.Equal(t, "nats://remote:4222", cfg.NatsURL) }, - }, - { - name: "HTTP_PORT overrides default", - envKey: "HTTP_PORT", - envVal: "9090", - check: func(t *testing.T, cfg *Config) { assert.Equal(t, "9090", cfg.HTTPPort) }, - }, - { - name: "PROD_MODE=true overrides default", - envKey: "PROD_MODE", - envVal: "true", - check: func(t *testing.T, cfg *Config) { assert.True(t, cfg.ProdMode) }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Setenv(tc.envKey, tc.envVal) - - cfg, err := loadConfig() - - require.NoError(t, err) - tc.check(t, cfg) - }) - } - }) - - t.Run("PROD_MODE with non-bool value returns error", func(t *testing.T) { - t.Setenv("PROD_MODE", "notabool") - - _, err := loadConfig() - - assert.Error(t, err) - }) -} - func TestStartHttpApi(t *testing.T) { t.Run("server addr reflects configured port", func(t *testing.T) { tests := []struct { diff --git a/backend/video-status/go.mod b/backend/video-status/go.mod index 53d0bf6..91780f3 100644 --- a/backend/video-status/go.mod +++ b/backend/video-status/go.mod @@ -3,7 +3,6 @@ module video-status go 1.26.2 require ( - github.com/kelseyhightower/envconfig v1.4.0 github.com/nats-io/nats.go v1.51.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go/modules/nats v0.42.0 @@ -58,8 +57,6 @@ require ( go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/sys v0.43.0 // indirect diff --git a/backend/video-status/go.sum b/backend/video-status/go.sum index e631ed4..71abf55 100644 --- a/backend/video-status/go.sum +++ b/backend/video-status/go.sum @@ -26,8 +26,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -41,25 +39,19 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= -github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= @@ -82,8 +74,6 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= -github.com/nats-io/nats.go v1.50.0 h1:5zAeQrTvyrKrWLJ0fu02W3br8ym57qf7csDzgLOpcds= -github.com/nats-io/nats.go v1.50.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= @@ -120,46 +110,28 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/backend/video-status/internal/handler/http.go b/backend/video-status/internal/handler/http.go index 8945516..dca2ac0 100644 --- a/backend/video-status/internal/handler/http.go +++ b/backend/video-status/internal/handler/http.go @@ -19,16 +19,18 @@ const ( ) type JobStatus struct { - State JobState `json:"state"` - Stage string `json:"stage"` - Error string `json:"error,omitempty"` + State JobState `json:"state"` + Stage string `json:"stage"` + Progress *int `json:"progress,omitempty"` + Error string `json:"error,omitempty"` } type jobStatusResponse struct { - JobID string `json:"job_id"` - State JobState `json:"state"` - Stage string `json:"stage"` - Error string `json:"error,omitempty"` + JobID string `json:"job_id"` + State JobState `json:"state"` + Stage string `json:"stage"` + Progress *int `json:"progress,omitempty"` + Error string `json:"error,omitempty"` } type JobStatusHandler struct { @@ -71,7 +73,7 @@ func (j *JobStatusHandler) PollJobStatus(w http.ResponseWriter, r *http.Request) } w.Header().Set("Content-Type", "application/json") - err = json.NewEncoder(w).Encode(jobStatusResponse{JobID: jobID, State: status.State, Stage: status.Stage, Error: status.Error}) + err = json.NewEncoder(w).Encode(jobStatusResponse{JobID: jobID, State: status.State, Stage: status.Stage, Progress: status.Progress, Error: status.Error}) if err != nil { j.Logger.Error("error encoding job status response", "err", err) } diff --git a/backend/video-status/internal/handler/subscriber.go b/backend/video-status/internal/handler/subscriber.go index 32caa5d..cfc785a 100644 --- a/backend/video-status/internal/handler/subscriber.go +++ b/backend/video-status/internal/handler/subscriber.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "log/slog" + "shared/handler" + "shared/kv" "time" "github.com/nats-io/nats.go" @@ -79,7 +81,7 @@ func ListenAdvisoriesFailure(nc *nats.Conn, js jetstream.JetStream, kv jetstream } // subs to jobs.complete (from video-recombiner service) via jetstream consumer and writes COMPLETE to KV -func ListenJobComplete(js jetstream.JetStream, kv jetstream.KeyValue, logger *slog.Logger) (jetstream.ConsumeContext, error) { +func ListenJobComplete(js jetstream.JetStream, jobStatusKV jetstream.KeyValue, logger *slog.Logger) (jetstream.ConsumeContext, error) { ctx := context.Background() streamName, err := js.StreamNameBySubject(ctx, "jobs.complete") @@ -105,35 +107,22 @@ func ListenJobComplete(js jetstream.JetStream, kv jetstream.KeyValue, logger *sl } consCtx, err := cons.Consume(func(msg jetstream.Msg) { - var payload jobIDPayload - - err := json.Unmarshal(msg.Data(), &payload) - if err != nil { - logger.Error("failed to unmarshal jobs.complete message", "err", err) - err := msg.Nak() - if err != nil { - logger.Error("failed to nak the msg on unmarshal", "err", err) - } + payload, ok := handler.UnmarshalJetstreamMsg[jobIDPayload](msg, logger) + if !ok { return } status, err := json.Marshal(JobStatus{State: StateComplete}) if err != nil { logger.Error("failed to marshal complete status", "err", err) - err := msg.Nak() - if err != nil { - logger.Error("failed to nak the msg on marshal", "err", err) - } + kv.NakWithErrHandling(logger, msg) return } - _, err = kv.Put(context.Background(), payload.JobID, status) + _, err = jobStatusKV.Put(context.Background(), payload.JobID, status) if err != nil { logger.Error("failed to write complete status to kv", "job_id", payload.JobID, "err", err) - err := msg.Nak() - if err != nil { - logger.Error("failed to nak the msg on keyvalue put", "err", err) - } + kv.NakWithErrHandling(logger, msg) return } diff --git a/backend/video-status/internal/handler/subscriber_integration_test.go b/backend/video-status/internal/handler/subscriber_integration_test.go index 428d7d2..8cb2fbf 100644 --- a/backend/video-status/internal/handler/subscriber_integration_test.go +++ b/backend/video-status/internal/handler/subscriber_integration_test.go @@ -99,8 +99,7 @@ func TestListenAdvisoriesFailure_WritesKV(t *testing.T) { } } -// TestListenAdvisoriesFailure_Ignored covers cases where the advisory handler -// encounters an error mid-way and leaves the KV unwritten. +// covers cases where the advisory handler encounters an error mid-way and leaves the KV unwritten. func TestListenAdvisoriesFailure_Ignored(t *testing.T) { tests := []struct { name string @@ -186,7 +185,11 @@ func TestListenJobCompleteI(t *testing.T) { url, err := container.ConnectionString(ctx) require.NoError(t, err) - nc, err := nats.Connect(url) + nc, err := nats.Connect(url, + nats.MaxReconnects(5), + nats.RetryOnFailedConnect(true), + nats.ReconnectWait(200*time.Millisecond), + ) require.NoError(t, err) t.Cleanup(nc.Close) diff --git a/backend/video-upload/cmd/helpers_test.go b/backend/video-upload/cmd/helpers_test.go new file mode 100644 index 0000000..c4ee5b1 --- /dev/null +++ b/backend/video-upload/cmd/helpers_test.go @@ -0,0 +1,54 @@ +//go:build unit || integration + +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" +) + +// patchOsExit replaces osExit with a recorder and restores it after the test. +func patchOsExit(t *testing.T) *int { + t.Helper() + code := new(int) + *code = -1 + osExit = func(c int) { *code = c } + t.Cleanup(func() { osExit = os.Exit }) + return code +} + +// patchNatsConnect replaces natsConnect with a stub that returns an error. +func patchNatsConnect(t *testing.T, err error) { + t.Helper() + natsConnect = func(_ string, _ ...nats.Option) (*nats.Conn, error) { return nil, err } + t.Cleanup(func() { natsConnect = nats.Connect }) +} + +// fakeStorageServer starts an httptest.Server that accepts any request and returns 200. +// Used to make storage.CheckHealth succeed so tests can reach later startup steps. +func fakeStorageServer(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + t.Cleanup(srv.Close) + return srv.URL +} + +// writeEnvFile creates ../.env with the given content and unsets the relevant env vars for the test. +func writeEnvFile(t *testing.T, content string) { + t.Helper() + for _, key := range []string{"NATS_URL", "PROD_MODE", "STORAGE_URL", "HTTP_PORT"} { + if old, set := os.LookupEnv(key); set { + t.Cleanup(func() { os.Setenv(key, old) }) //nolint:errcheck + } else { + t.Cleanup(func() { os.Unsetenv(key) }) //nolint:errcheck + } + os.Unsetenv(key) //nolint:errcheck + } + require.NoError(t, os.WriteFile("../.env", []byte(content), 0600)) + t.Cleanup(func() { _ = os.Remove("../.env") }) +} diff --git a/backend/video-upload/cmd/main.go b/backend/video-upload/cmd/main.go index 5cdfea0..7210bdc 100644 --- a/backend/video-upload/cmd/main.go +++ b/backend/video-upload/cmd/main.go @@ -1,18 +1,17 @@ package main import ( - "context" "log" "os" "os/signal" + shandler "shared/handler" + "shared/kv" "shared/middleware" + "shared/service" "shared/storage" "syscall" - "time" "video-upload/internal/handler" - "github.com/joho/godotenv" - "github.com/kelseyhightower/envconfig" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) @@ -24,8 +23,11 @@ type Config struct { HTTPPort string `envconfig:"HTTP_PORT" default:"8080"` } +var osExit = os.Exit +var natsConnect = nats.Connect + func main() { - cfg, err := loadConfig() + cfg, err := service.LoadConfig[Config]() if err != nil { log.Fatalf("failed to load config values: %v", err) } @@ -35,22 +37,25 @@ func main() { err = storage.CheckHealth(cfg.StorageURL, logger) if err != nil { logger.Error("storage seedweedfs unreachable", "url", cfg.StorageURL, "err", err) - os.Exit(1) + osExit(1) + return } - nc, err := nats.Connect(cfg.NatsURL) + nc, err := natsConnect(cfg.NatsURL) if err != nil { logger.Error("unable to connect to nats", "err", err) - os.Exit(1) + osExit(1) + return } js, err := jetstream.New(nc) if err != nil { logger.Error("unable to connect to jetstream", "err", err) - os.Exit(1) + osExit(1) + return } - kv := handler.ConnectJobStatusKV(js, logger) + kv := kv.ConnectJobStatus(js, logger) quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM) @@ -61,28 +66,10 @@ func main() { <-quit - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := server.Shutdown(ctx); err != nil { - logger.Warn("http server shutdown error", "err", err) - } - - if err := nc.Drain(); err != nil { - logger.Warn("nats drain error", "err", err) - } -} + shandler.ShutdownHttpServer(server, logger) -func loadConfig() (*Config, error) { - err := godotenv.Load("../.env") + err = nc.Drain() if err != nil { - log.Println("missing .env file") - } - var cfg Config - - err = envconfig.Process("", &cfg) - if err != nil { - return nil, err + logger.Warn("nats drain error", "err", err) } - - return &cfg, nil } diff --git a/backend/video-upload/cmd/main_integration_test.go b/backend/video-upload/cmd/main_integration_test.go index 6a3a8c3..0203843 100644 --- a/backend/video-upload/cmd/main_integration_test.go +++ b/backend/video-upload/cmd/main_integration_test.go @@ -8,7 +8,10 @@ import ( "fmt" "net/http" "os" + shandler "shared/handler" + stest "shared/test" "strings" + "syscall" "testing" "time" "video-upload/internal/handler" @@ -23,7 +26,7 @@ import ( var sharedStorageURL string func TestMain(m *testing.M) { - url, cleanup := test.StartSeaweedFSFiler() + url, cleanup := stest.StartSeaweedFSFiler() sharedStorageURL = url code := m.Run() cleanup() @@ -39,7 +42,7 @@ type serverEnv struct { func setupServer(t *testing.T) *serverEnv { t.Helper() - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) cfg := &Config{HTTPPort: test.FreePort(t), StorageURL: sharedStorageURL} @@ -89,7 +92,7 @@ func TestStartHttpApi(t *testing.T) { { name: "POST /jobs/upload is wired to the upload handler", buildReq: func() *http.Request { - return test.NewUploadRequest(t, env.url+"/jobs/upload", "clip.mp4", []byte("data"), "1080p") + return test.NewUploadRequest(t, env.url+"/jobs/upload", "clip.mp4", test.TestVideoBytes(t), "1080p", "1080p", "Transcode") }, wantStatus: http.StatusCreated, }, @@ -126,7 +129,7 @@ func TestUploadPipeline(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = sub.Unsubscribe() }) - req := test.NewUploadRequest(t, env.url+"/jobs/upload", "video.mp4", []byte("data"), "720p") + req := test.NewUploadRequest(t, env.url+"/jobs/upload", "video.mp4", test.TestVideoBytes(t), "720p", "1080p", "Transcode") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -151,7 +154,7 @@ func TestUploadPipeline(t *testing.T) { // Verify NATS scene-split message was published select { case data := <-received: - var msg handler.SceneSplitMessage + var msg shandler.VideoJobMessage require.NoError(t, json.Unmarshal(data, &msg)) assert.Equal(t, uploadResp.JobID, msg.JobID) assert.Equal(t, "720p", msg.TargetResolution) @@ -165,7 +168,7 @@ func TestUploadPipeline(t *testing.T) { jobIDs := make([]string, 3) for i := range jobIDs { - req := test.NewUploadRequest(t, env.url+"/jobs/upload", "video.mp4", []byte("data"), "1080p") + req := test.NewUploadRequest(t, env.url+"/jobs/upload", "video.mp4", test.TestVideoBytes(t), "1080p", "1080p", "Transcode") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() @@ -207,8 +210,44 @@ func TestGracefulShutdown(t *testing.T) { }) t.Run("NATS drain completes without error on a healthy connection", func(t *testing.T) { - _, nc := test.SetupNats(t) + _, nc := stest.SetupNats(t) assert.NoError(t, nc.Drain()) }) } + +// runs and shuts down cleanly on SIGINT +func TestMainFuncLifecycle(t *testing.T) { + js, nc := stest.SetupNats(t) + test.SetupKV(t, js) // ConnectJobStatusKV expects the bucket to already exist + + port := test.FreePort(t) + writeEnvFile(t, fmt.Sprintf( + "NATS_URL=%s\nSTORAGE_URL=%s\nHTTP_PORT=%s\n", + nc.ConnectedUrl(), sharedStorageURL, port, + )) + + done := make(chan struct{}) + go func() { + defer close(done) + main() + }() + + serverURL := "http://localhost:" + port + require.Eventually(t, func() bool { + resp, err := http.Post(serverURL+"/jobs/upload", "text/plain", nil) + if err != nil { + return false + } + resp.Body.Close() + return true + }, 15*time.Second, 100*time.Millisecond, "main() server did not start") + + require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGINT)) + + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("main() did not return after SIGINT") + } +} diff --git a/backend/video-upload/cmd/main_unit_test.go b/backend/video-upload/cmd/main_unit_test.go index 6ded5cf..63296c4 100644 --- a/backend/video-upload/cmd/main_unit_test.go +++ b/backend/video-upload/cmd/main_unit_test.go @@ -3,77 +3,40 @@ package main import ( - "os" - "os/exec" - "path/filepath" - "strings" + "errors" "testing" - "video-upload/internal/test" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestLoadConfig(t *testing.T) { - t.Run("missing env file shouldnt return error", func(t *testing.T) { - if _, err := os.Stat(filepath.Join("..", ".env")); err == nil { - t.Skip(".env already exists") - } - - _, err := loadConfig() - - assert.NoError(t, err) - }) - - t.Run("reads all values from env file", func(t *testing.T) { - test.WriteEnvFile(t, "NATS_URL=nats://test:9999\nPROD_MODE=true\nSTORAGE_URL=http://storage:9333\nHTTP_PORT=9090\n") - - cfg, err := loadConfig() - - require.NoError(t, err) - assert.Equal(t, "nats://test:9999", cfg.NatsURL) - assert.True(t, cfg.ProdMode) - assert.Equal(t, "http://storage:9333", cfg.StorageURL) - assert.Equal(t, "9090", cfg.HTTPPort) - }) - - t.Run("empty env file uses struct defaults", func(t *testing.T) { - test.WriteEnvFile(t, "") - - cfg, err := loadConfig() - - require.NoError(t, err) - assert.Equal(t, "nats://localhost:4222", cfg.NatsURL) - assert.False(t, cfg.ProdMode) - assert.Equal(t, "http://localhost:8888", cfg.StorageURL) - assert.Equal(t, "8080", cfg.HTTPPort) - }) -} - func TestMainFunc(t *testing.T) { - t.Run("exits on NATS connect error", func(t *testing.T) { - if os.Getenv("RUN_MAIN") == "nats_error" { - main() - return - } - - test.WriteEnvFile(t, "NATS_URL=nats://localhost:1\n") + cases := []struct { + name string + setup func(t *testing.T) + }{ + { + name: "exits when storage is unreachable", + setup: func(t *testing.T) { + writeEnvFile(t, "STORAGE_URL=http://localhost:1\n") + }, + }, + { + name: "exits when nats is unreachable", + setup: func(t *testing.T) { + writeEnvFile(t, "STORAGE_URL="+fakeStorageServer(t)+"\nNATS_URL=nats://localhost:1\n") + patchNatsConnect(t, errors.New("nats unreachable")) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code := patchOsExit(t) + tc.setup(t) - var env []string - for _, e := range os.Environ() { - if !strings.HasPrefix(e, "NATS_URL=") && !strings.HasPrefix(e, "PROD_MODE=") && - !strings.HasPrefix(e, "STORAGE_URL=") && !strings.HasPrefix(e, "HTTP_PORT=") { - env = append(env, e) - } - } - - cmd := exec.Command(os.Args[0], "-test.run=TestMain/exits_on_NATS_connect_error", "-test.count=1") - cmd.Env = append(env, "RUN_MAIN=nats_error") - err := cmd.Run() - - var exitErr *exec.ExitError + main() - require.ErrorAs(t, err, &exitErr) - assert.Equal(t, 1, exitErr.ExitCode()) - }) + assert.Equal(t, 1, *code) + }) + } } diff --git a/backend/video-upload/cmd/makefile b/backend/video-upload/cmd/makefile index 822bfc9..571aec7 100644 --- a/backend/video-upload/cmd/makefile +++ b/backend/video-upload/cmd/makefile @@ -1,6 +1,6 @@ test_all: integration unit -PKGS := . ../internal/handler/... ../internal/storage/... +PKGS := . ../internal/handler/... ../internal/storage/... format: go fmt ${PKGS} . diff --git a/backend/video-upload/go.mod b/backend/video-upload/go.mod index 1ab6969..f7b4cb4 100644 --- a/backend/video-upload/go.mod +++ b/backend/video-upload/go.mod @@ -5,12 +5,9 @@ go 1.26.1 require ( github.com/go-playground/validator/v10 v10.30.2 github.com/google/uuid v1.6.0 - github.com/joho/godotenv v1.5.1 - github.com/kelseyhightower/envconfig v1.4.0 github.com/nats-io/nats.go v1.50.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.41.0 - github.com/testcontainers/testcontainers-go/modules/nats v0.41.0 ) require ( @@ -18,6 +15,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect @@ -65,8 +63,10 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/proto/otlp v1.0.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/backend/video-upload/go.sum b/backend/video-upload/go.sum index b990ff1..8542360 100644 --- a/backend/video-upload/go.sum +++ b/backend/video-upload/go.sum @@ -63,10 +63,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= -github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -127,8 +123,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais= github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI= -github.com/testcontainers/testcontainers-go/modules/nats v0.41.0 h1:ONiEuMwUgOLL3DiIHDgS4NvjwsSND4zkAhFlxkXWdb0= -github.com/testcontainers/testcontainers-go/modules/nats v0.41.0/go.mod h1:XSx4gGxmUaj3EHx3+ySOjge3ZCfOeV6yq/SrNhoYmso= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= diff --git a/backend/video-upload/internal/handler/http.go b/backend/video-upload/internal/handler/http.go index 6a33e5f..4f5aef0 100644 --- a/backend/video-upload/internal/handler/http.go +++ b/backend/video-upload/internal/handler/http.go @@ -52,12 +52,6 @@ type uploadResponse struct { JobID string `json:"job_id"` } -type SceneSplitMessage struct { - JobID string `json:"job_id"` - TargetResolution string `json:"target_resolution"` - StorageURL string `json:"storage_url"` -} - // handler for video upload POST requests, Accepts a multipart video upload, saves it to disk, // and publishes a scene split message to NATS for downstream processing func (v *videoHandler) uploadVideoRoute(w http.ResponseWriter, r *http.Request) { @@ -96,6 +90,37 @@ func (v *videoHandler) uploadVideoRoute(w http.ResponseWriter, r *http.Request) return } + sourceRes := r.FormValue("source_resolution") + if sourceRes == "" { + http.Error(w, "missing source_resolution field", http.StatusBadRequest) + v.logger.Error("missing source_resolution field") + return + } + + processType := r.FormValue("process_type") + if targetRes == "" { + http.Error(w, "missing process_type field", http.StatusBadRequest) + v.logger.Error("missing process_type field") + return + } + + var pubSubject string + + switch processType { + case "Transcode": + pubSubject = "jobs.video.scene-split" + case "Upscale": + pubSubject = "jobs.video.upscale" + case "Denoise": + pubSubject = "jobs.video.denoise" + case "Convert": + pubSubject = "jobs.video.convert" + default: + pubSubject = "jobs.video.scene-split" + } + + v.logger.Debug("pubsubject called", "subject", pubSubject) + result, err := storage.SaveUploadedVideo(file, v.storageURL, header.Filename) if err != nil { http.Error(w, "failed to save uploaded video", http.StatusInternalServerError) @@ -103,16 +128,16 @@ func (v *videoHandler) uploadVideoRoute(w http.ResponseWriter, r *http.Request) return } - const pubSubject = "jobs.video.scene-split" + v.logger.Debug("pubSubject is", "pubSubject", pubSubject) err = handler.PublishJobComplete( - v.js, SceneSplitMessage{ - JobID: result.JobID, TargetResolution: targetRes, StorageURL: result.StorageURL, + v.js, handler.VideoJobMessage{ + JobID: result.JobID, TargetResolution: targetRes, SourceResolution: sourceRes, StorageURL: result.StorageURL, }, pubSubject, ) if err != nil { http.Error(w, "unable to send process request msg to system", http.StatusInternalServerError) - v.logger.Error("error publishing split video request to nats", "err", err) + v.logger.Error("error publishing request to nats", "err", err) return } diff --git a/backend/video-upload/internal/handler/http_integration_test.go b/backend/video-upload/internal/handler/http_integration_test.go index 939eaf3..30447ab 100644 --- a/backend/video-upload/internal/handler/http_integration_test.go +++ b/backend/video-upload/internal/handler/http_integration_test.go @@ -11,6 +11,8 @@ import ( "net/http" "net/http/httptest" "os" + "shared/handler" + stest "shared/test" "testing" "time" "video-upload/internal/test" @@ -24,7 +26,7 @@ import ( var sharedFilerUrl string func TestMain(m *testing.M) { - filerURL, cleanup := test.StartSeaweedFSFiler() + filerURL, cleanup := stest.StartSeaweedFSFiler() sharedFilerUrl = filerURL code := m.Run() @@ -55,7 +57,7 @@ func newDownloadVideoServer(t *testing.T, storageURL string) *httptest.Server { // covers the full upload pipeline: multipart form → SeaweedFS → NATS → response. func TestUploadVideoFlow(t *testing.T) { - js, nc := test.SetupNats(t) + js, nc := stest.SetupNats(t) kv := test.SetupKV(t, js) h := newUploadHandler(js, kv, sharedFilerUrl) @@ -63,7 +65,7 @@ func TestUploadVideoFlow(t *testing.T) { h.maxUploadBytes = 100 defer func() { h.maxUploadBytes = 0 }() - req := test.NewUploadRequest(t, "/jobs", "big.mp4", bytes.Repeat([]byte("x"), 200), "1080p") + req := test.NewUploadRequest(t, "/jobs", "big.mp4", bytes.Repeat([]byte("x"), 200), "1080p", "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) @@ -73,7 +75,7 @@ func TestUploadVideoFlow(t *testing.T) { }) t.Run("File is saved to SeaweedFS and is fetchable at the returned StorageURL", func(t *testing.T) { - req := test.NewUploadRequest(t, "/jobs", "clip.mp4", []byte("fake video bytes"), "1080p") + req := test.NewUploadRequest(t, "/jobs", "clip.mp4", test.TestVideoBytes(t), "1080p", "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) @@ -87,8 +89,8 @@ func TestUploadVideoFlow(t *testing.T) { }) t.Run("Saved file contains the exact bytes that were uploaded", func(t *testing.T) { - content := []byte("precise video content") - req := test.NewUploadRequest(t, "/jobs", "video.mp4", content, "720p") + content := test.TestVideoBytes(t) + req := test.NewUploadRequest(t, "/jobs", "video.mp4", content, "720p", "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) @@ -116,7 +118,7 @@ func TestUploadVideoFlow(t *testing.T) { require.NoError(t, err) defer sub.Unsubscribe() - req := test.NewUploadRequest(t, "/jobs", "video.mp4", []byte("data"), "720p") + req := test.NewUploadRequest(t, "/jobs", "video.mp4", test.TestVideoBytes(t), "720p", "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) require.Equal(t, http.StatusCreated, rec.Code) @@ -128,7 +130,7 @@ func TestUploadVideoFlow(t *testing.T) { select { case data := <-received: - var msg SceneSplitMessage + var msg handler.VideoJobMessage require.NoError(t, json.Unmarshal(data, &msg)) assert.Equal(t, uploadResp.JobID, msg.JobID) assert.Equal(t, "720p", msg.TargetResolution) @@ -142,7 +144,7 @@ func TestUploadVideoFlow(t *testing.T) { seen := make(map[string]bool) for range 3 { - req := test.NewUploadRequest(t, "/jobs", "video.mp4", []byte("data"), "1080p") + req := test.NewUploadRequest(t, "/jobs", "video.mp4", test.TestVideoBytes(t), "1080p", "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) require.Equal(t, http.StatusCreated, rec.Code) @@ -158,7 +160,7 @@ func TestUploadVideoFlow(t *testing.T) { t.Run("Large file (5 MB) is fully persisted to SeaweedFS", func(t *testing.T) { content := bytes.Repeat([]byte("x"), 5*1024*1024) - req := test.NewUploadRequest(t, "/jobs", "big.mp4", content, "4k") + req := test.NewUploadRequest(t, "/jobs", "big.mp4", content, "4k", "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) @@ -180,7 +182,7 @@ func TestUploadVideoFlow(t *testing.T) { t.Run("Returns 500 when NATS publish fails after successful storage save", func(t *testing.T) { h := newUploadHandler(&test.MockJS{PublishErr: errors.New("nats unavailable")}, &test.MockKV{}, sharedFilerUrl) - req := test.NewUploadRequest(t, "/jobs", "video.mp4", []byte("data"), "1080p") + req := test.NewUploadRequest(t, "/jobs", "video.mp4", []byte("data"), "1080p", "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) diff --git a/backend/video-upload/internal/handler/http_unit_test.go b/backend/video-upload/internal/handler/http_unit_test.go index c83b061..759dde6 100644 --- a/backend/video-upload/internal/handler/http_unit_test.go +++ b/backend/video-upload/internal/handler/http_unit_test.go @@ -111,7 +111,7 @@ func TestUploadVideo(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { h := newVideoHandler("http://localhost:1", &test.MockJS{}) - req := test.NewUploadRequest(t, "/jobs", tc.fileName, tc.content, tc.targetRes) + req := test.NewUploadRequest(t, "/jobs", tc.fileName, tc.content, tc.targetRes, "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) @@ -124,7 +124,7 @@ func TestUploadVideo(t *testing.T) { t.Run("Returns 500 when saving the video file fails", func(t *testing.T) { // Null byte in path causes os.MkdirAll to fail h := newVideoHandler("\x00", &test.MockJS{}) - req := test.NewUploadRequest(t, "/jobs", "video.mp4", []byte("data"), "1080p") + req := test.NewUploadRequest(t, "/jobs", "video.mp4", []byte("data"), "1080p", "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) @@ -137,7 +137,7 @@ func TestUploadVideo(t *testing.T) { kv := &test.MockKV{PutErr: errors.New("kv unavailable")} server, _ := startTestServer(t, kv) - req := test.NewUploadRequest(t, "/jobs/upload", "video.mp4", []byte("data"), "1080p") + req := test.NewUploadRequest(t, "/jobs/upload", "video.mp4", []byte("data"), "1080p", "1080p", "Transcode") w := httptest.NewRecorder() server.Handler.ServeHTTP(w, req) @@ -148,7 +148,7 @@ func TestUploadVideo(t *testing.T) { t.Run("Does not publish to NATS when saving fails", func(t *testing.T) { js := &test.MockJS{} h := newVideoHandler("\x00", js) - req := test.NewUploadRequest(t, "/jobs", "video.mp4", []byte("data"), "1080p") + req := test.NewUploadRequest(t, "/jobs", "video.mp4", []byte("data"), "1080p", "1080p", "Transcode") rec := httptest.NewRecorder() h.uploadVideoRoute(rec, req) diff --git a/backend/video-upload/internal/handler/job_status_kv.go b/backend/video-upload/internal/handler/job_status_kv.go index fceb601..5fd62ca 100644 --- a/backend/video-upload/internal/handler/job_status_kv.go +++ b/backend/video-upload/internal/handler/job_status_kv.go @@ -4,28 +4,10 @@ import ( "context" "encoding/json" "log/slog" - "os" - "time" "github.com/nats-io/nats.go/jetstream" ) -var osExit = os.Exit - -// connect to existing job status kv to publishing the processing stage update msgs -func ConnectJobStatusKV(js jetstream.JetStream, logger *slog.Logger) jetstream.KeyValue { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - kv, err := js.KeyValue(ctx, "job-status") - if err != nil { - logger.Error("failed to connect to job-status kv bucket", "err", err) - osExit(1) - } - - return kv -} - func updateJobStatusKV(ctx context.Context, jobID string, kv jetstream.KeyValue, logger *slog.Logger) error { status, err := json.Marshal(struct { State string `json:"state"` diff --git a/backend/video-upload/internal/storage/queries_integration_test.go b/backend/video-upload/internal/storage/queries_integration_test.go index fe4ce4c..9e5cb28 100644 --- a/backend/video-upload/internal/storage/queries_integration_test.go +++ b/backend/video-upload/internal/storage/queries_integration_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + stest "shared/test" "testing" "video-upload/internal/storage" "video-upload/internal/test" @@ -18,7 +19,7 @@ import ( var sharedFilerUrl string func TestMain(m *testing.M) { - filerURL, cleanup := test.StartSeaweedFSFiler() + filerURL, cleanup := stest.StartSeaweedFSFiler() sharedFilerUrl = filerURL code := m.Run() @@ -164,9 +165,11 @@ func TestGetProcessedVideo(t *testing.T) { seedReq, err := http.NewRequest(http.MethodPut, sharedFilerUrl+"/job123/"+fileName+"/processed", test.OpenTestVideo(t)) require.NoError(t, err) + seedReq.Header.Set("Content-Type", "application/octet-stream") seedResp, err := http.DefaultClient.Do(seedReq) require.NoError(t, err) + seedResp.Body.Close() require.Less(t, seedResp.StatusCode, 400) diff --git a/backend/video-upload/internal/test/http_fixtures.go b/backend/video-upload/internal/test/http_fixtures.go index 3d06feb..e07334c 100644 --- a/backend/video-upload/internal/test/http_fixtures.go +++ b/backend/video-upload/internal/test/http_fixtures.go @@ -1,9 +1,9 @@ package test import ( + _ "embed" "errors" "fmt" - "github.com/stretchr/testify/require" "io" "log/slog" "net" @@ -11,8 +11,13 @@ import ( "strconv" "strings" "testing" + + "github.com/stretchr/testify/require" ) +//go:embed testvideo.mp4 +var testVideo []byte + func SilentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } @@ -47,6 +52,13 @@ func FreePort(t *testing.T) string { return port } +// TestVideoBytes returns the bytes of testvideo.mp4 for use in upload integration tests. +func TestVideoBytes(t *testing.T) []byte { + t.Helper() + require.NotEmpty(t, testVideo, "embedded testvideo.mp4 is empty") + return testVideo +} + // NewDownloadRequest builds a GET request with a JSON body containing job_id and file_name. func NewDownloadRequest(t *testing.T, jobID, fileName string) *http.Request { t.Helper() diff --git a/backend/video-upload/internal/test/nats_fixtures.go b/backend/video-upload/internal/test/nats_fixtures.go index e006884..6e7d33c 100644 --- a/backend/video-upload/internal/test/nats_fixtures.go +++ b/backend/video-upload/internal/test/nats_fixtures.go @@ -5,49 +5,14 @@ package test import ( "context" "testing" - "time" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "github.com/stretchr/testify/require" tc "github.com/testcontainers/testcontainers-go" - natstc "github.com/testcontainers/testcontainers-go/modules/nats" "github.com/testcontainers/testcontainers-go/wait" ) -// fixture for setting up nats container for testing -func SetupNats(t *testing.T) (jetstream.JetStream, *nats.Conn) { - t.Helper() - ctx := context.Background() - - container, err := natstc.Run(ctx, "nats:2.10-alpine") - require.NoError(t, err) - t.Cleanup(func() { _ = container.Terminate(ctx) }) - - url, err := container.ConnectionString(ctx) - require.NoError(t, err) - - nc, err := nats.Connect(url, - nats.RetryOnFailedConnect(true), - nats.MaxReconnects(10), - nats.ReconnectWait(200*time.Millisecond), - ) - - require.NoError(t, err) - t.Cleanup(nc.Close) - - js, err := jetstream.New(nc) - require.NoError(t, err) - - _, err = js.CreateStream(ctx, jetstream.StreamConfig{ - Name: "jobs", - Subjects: []string{"jobs.>"}, - }) - require.NoError(t, err) - - return js, nc -} - // SetupNatsNoJetStream starts a plain NATS container without JetStream enabled // and returns the connection. Use this to test behaviour when JetStream is unavailable. func SetupNatsNoJetStream(t *testing.T) *nats.Conn { diff --git a/backend/video-upload/internal/test/service_fixtures.go b/backend/video-upload/internal/test/service_fixtures.go index dc88a29..0613e49 100644 --- a/backend/video-upload/internal/test/service_fixtures.go +++ b/backend/video-upload/internal/test/service_fixtures.go @@ -4,8 +4,6 @@ import ( "bytes" "mime/multipart" "net/http" - "os" - "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -14,8 +12,7 @@ import ( // NewUploadRequest builds a multipart POST request. // Pass a path ("/jobs") for direct handler invocation via httptest.NewRecorder, // or a full URL ("http://host:port/jobs") for use with http.DefaultClient. -// Pass filename="" to omit the video field. Pass targetRes="" to omit target_resolution. -func NewUploadRequest(t *testing.T, target, filename string, fileContent []byte, targetRes string) *http.Request { +func NewUploadRequest(t *testing.T, target, filename string, fileContent []byte, targetRes, sourceRes, processType string) *http.Request { t.Helper() var body bytes.Buffer w := multipart.NewWriter(&body) @@ -31,6 +28,14 @@ func NewUploadRequest(t *testing.T, target, filename string, fileContent []byte, require.NoError(t, w.WriteField("target_resolution", targetRes)) } + if sourceRes != "" { + require.NoError(t, w.WriteField("source_resolution", sourceRes)) + } + + if processType != "" { + require.NoError(t, w.WriteField("process_type", processType)) + } + require.NoError(t, w.Close()) req, err := http.NewRequest(http.MethodPost, target, &body) @@ -38,26 +43,3 @@ func NewUploadRequest(t *testing.T, target, filename string, fileContent []byte, req.Header.Set("Content-Type", w.FormDataContentType()) return req } - -// for main.go unit tests -func WriteEnvFile(t *testing.T, content string) { - t.Helper() - for _, key := range []string{"NATS_URL", "PROD_MODE", "STORAGE_URL", "HTTP_PORT"} { - if old, set := os.LookupEnv(key); set { - t.Cleanup(func() { - err := os.Setenv(key, old) - require.NoError(t, err) - }) - } else { - t.Cleanup(func() { - err := os.Unsetenv(key) - require.NoError(t, err) - }) - } - err := os.Unsetenv(key) - require.NoError(t, err) - } - path := filepath.Join("..", ".env") - require.NoError(t, os.WriteFile(path, []byte(content), 0600)) - t.Cleanup(func() { _ = os.Remove(path) }) -} diff --git a/backend/video-upload/internal/test/storage.go b/backend/video-upload/internal/test/storage.go index dd51f93..17532f1 100644 --- a/backend/video-upload/internal/test/storage.go +++ b/backend/video-upload/internal/test/storage.go @@ -4,48 +4,14 @@ package test import ( "bytes" - "context" "fmt" "net/http" "os" "testing" "github.com/stretchr/testify/require" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" ) -// StartSeaweedFSFiler starts a SeaweedFS filer container and returns the filer -// URL and a cleanup function. Use this when t.Cleanup is not available (e.g. TestMain). -func StartSeaweedFSFiler() (string, func()) { - ctx := context.Background() - - req := testcontainers.ContainerRequest{ - Image: "chrislusf/seaweedfs", - Cmd: []string{"server", "-dir=/data", "-master.port=9333", "-volume.port=8080", "-filer"}, - ExposedPorts: []string{"9333/tcp", "8888/tcp"}, - WaitingFor: wait.ForAll( - wait.ForHTTP("/dir/status").WithPort("9333/tcp").WithStatusCodeMatcher(func(status int) bool { return status < 500 }), - wait.ForHTTP("/").WithPort("8888/tcp").WithStatusCodeMatcher(func(status int) bool { return status < 500 }), - ), - } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - panic("failed to start SeaweedFS container: " + err.Error()) - } - - endpoint, err := container.PortEndpoint(ctx, "8888/tcp", "http") - if err != nil { - panic("failed to get SeaweedFS filer endpoint: " + err.Error()) - } - - return endpoint, func() { _ = container.Terminate(ctx) } -} - const testVideoPath = "../test/testvideo.mp4" // helper to open the test video diff --git a/backend/video-upscaling/.python-version b/backend/video-upscaling/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/backend/video-upscaling/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/backend/video-upscaling/makefile b/backend/video-upscaling/makefile new file mode 100644 index 0000000..03ab917 --- /dev/null +++ b/backend/video-upscaling/makefile @@ -0,0 +1,11 @@ +test_all: integration unit + +unit: + uv run pytest tests/unit/ + +integration: + uv run pytest tests/integration/ + +coverage: + uv run pytest --cov=src --cov-report= tests/unit/ + uv run pytest --cov=src --cov-report=xml:coverage.xml --cov-append tests/integration/ \ No newline at end of file diff --git a/backend/video-upscaling/pyproject.toml b/backend/video-upscaling/pyproject.toml new file mode 100644 index 0000000..f2b7ded --- /dev/null +++ b/backend/video-upscaling/pyproject.toml @@ -0,0 +1,69 @@ +[project] +name = "video-upscaling" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.13" +dependencies = [ + "structlog", + "requests", + "opencv-python-headless", + "nats-py", + "realesrgan>=0.3.0", + "torch", + "torchvision", + "basicsr-fixed", + "pydantic-settings>=2.13.1", + "shared_python", + "types-requests>=2.33.0.20260408", +] + +[tool.uv.sources] +shared_python = { path = "../shared/python", editable = true } +torch = [ + { index = "pytorch-cu128", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +torchvision = [ + { index = "pytorch-cu128", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[tool.pyrefly] +python-version = "3.13" +untyped-def-behavior = "check-and-infer-return-any" +search-path = [".", "../shared/python/src"] +venv = ".venv" + +[tool.uv.workspace] +members = [ + "video-upscaling", +] + +[[tool.uv.index]] +name = "pytorch-cu128" +url = "https://download.pytorch.org/whl/cu128" +explicit = true + +[tool.pyrefly.errors] +unannotated-parameter = "error" +unannotated-return = "error" +unannotated-attribute = "error" +implicit-any = "error" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = [".", "src"] +asyncio_mode = "auto" +filterwarnings = [ + "ignore::DeprecationWarning:testcontainers", +] + +[tool.ruff] +exclude = ["src/processing/video.py"] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.25.0", + "pytest-cov>=7.1.0", + "testcontainers[nats]>=4.0.0", + "ruff>=0.15.11", +] diff --git a/backend/video-upscaling/src/core/settings.py b/backend/video-upscaling/src/core/settings.py new file mode 100644 index 0000000..f86bd6c --- /dev/null +++ b/backend/video-upscaling/src/core/settings.py @@ -0,0 +1,25 @@ +from pathlib import Path +from pydantic_settings import BaseSettings + +PROJECT_ROOT = Path(__file__).parent.parent.parent +ENV_FILE = PROJECT_ROOT / ".env" + + +class Settings(BaseSettings): + # general config + HTTP_PORT: int = 9101 + BATCH_SIZE: int = 4 + SERVICE_NAME: str = "video-upscaling" + + # Nats config + NATS_URL: str = "nats://localhost:4222" + SUB_SUBJECT: str = "jobs.video.upscale" + SUB_QUEUE_NAME: str = "video-upscaling-workers" + PUB_SUBJECT: str = "jobs.complete" + MAX_DELIVER_ATTEMPTS: int = 3 + ACK_WAIT_S: int = 30 + + BASE_STORAGE_URL: str = "http://localhost:8888" + + +settings = Settings() diff --git a/backend/video-upscaling/src/processing/batch.py b/backend/video-upscaling/src/processing/batch.py new file mode 100644 index 0000000..b770a90 --- /dev/null +++ b/backend/video-upscaling/src/processing/batch.py @@ -0,0 +1,85 @@ +from queue import Queue +from typing import Optional +from realesrgan import RealESRGANer +from time import perf_counter +import torch +import numpy as np + + +def flush_batch( + upsampler: RealESRGANer, + frames: list[np.ndarray], + encode_queue: Queue[Optional[bytes]], +) -> tuple[float, float, int]: + """ + Runs one batch of frames through GPU model and queues the results for encoding + 1. Calls infer_batch to send the batch of raw frames through the upscaler model + 2. puts each result in encoder_queue for encoder_worker to write to ffmpeg + + Args: + upsampler: the upscaling model + frames: list containing the batch of unupscaled video frames to process + encode_queue: the queue that upscaled images are written to + + Returns: + a tuple containing timing metrics and frame count for processing stats + """ + t0 = perf_counter() + results = _infer_batch(upsampler.model, frames) + + t1 = perf_counter() + for r in results: + encode_queue.put(r) + + t2 = perf_counter() + + return t1 - t0, t2 - t1, len(frames) + + +def _infer_batch(model: torch.nn.Module, frames_bgr: list[np.ndarray]) -> list[bytes]: + """ + Upscales a batch of BGR frames. Converts to YUV420p on GPU before CPU transfer + to minimize PCIe bandwidth and maximize calculations on gpu for speed + """ + tensors = [ + torch.from_numpy(f[:, :, ::-1].copy()).permute(2, 0, 1).half() / 255.0 + for f in frames_bgr + ] + batch = torch.stack(tensors).cuda() # (N, 3, H, W) fp16 + + with torch.no_grad(): + with torch.autocast(device_type="cuda"): + out = model(batch) # (N, 3, out_H, out_W) + + # convert to yuv420p on gpu before dtoh to reduce pipe data requirements + R, G, B = out[:, 0], out[:, 1], out[:, 2] + Y = (16 + 65.481 * R + 128.553 * G + 24.966 * B).clamp(16, 235).byte().cpu().numpy() + U = ( + ( + 128 + - 37.797 * R[:, ::2, ::2] + - 74.203 * G[:, ::2, ::2] + + 112.0 * B[:, ::2, ::2] + ) + .clamp(16, 240) + .byte() + .cpu() + .numpy() + ) + V = ( + ( + 128 + + 112.0 * R[:, ::2, ::2] + - 93.786 * G[:, ::2, ::2] + - 18.214 * B[:, ::2, ::2] + ) + .clamp(16, 240) + .byte() + .cpu() + .numpy() + ) + + return [ + np.concatenate([Y[i].ravel(), U[i].ravel(), V[i].ravel()]).tobytes() + for i in range(len(frames_bgr)) + ] diff --git a/backend/video-upscaling/src/processing/load_model.py b/backend/video-upscaling/src/processing/load_model.py new file mode 100644 index 0000000..d10d045 --- /dev/null +++ b/backend/video-upscaling/src/processing/load_model.py @@ -0,0 +1,45 @@ +from pathlib import Path +from realesrgan import RealESRGANer +from realesrgan.archs.srvgg_arch import SRVGGNetCompact +import torch + + +def load_model(model_path: Path, scale: int) -> RealESRGANer: + """ + Loads a RealESRGAN upscaler model onto CUDA with fp16 and torch.compile optimization. + Tiling is disabled so full-frame inference only. + + Args: + model_path: the path to the model weights + scale: the upscaling ratio for the model to use + + Returns: + The loaded RealESRGANer model + + Raises: + ValueError if the scale isnt 2 or 4 since we only support x2 and x4 upscaling + """ + if scale not in (2, 4): + raise ValueError("scale must be either 2 or 4") + + model = SRVGGNetCompact( + num_in_ch=3, + num_out_ch=3, + num_feat=64, + num_conv=16, + upscale=scale, + act_type="prelu", + ) + + upsampler = RealESRGANer( + scale=scale, + model_path=str(model_path), + model=model, + tile=0, + tile_pad=10, + pre_pad=0, + device="cuda", + ) + upsampler.model = upsampler.model.half() + upsampler.model = torch.compile(upsampler.model, mode="reduce-overhead") + return upsampler diff --git a/backend/video-upscaling/src/processing/nats_msg.py b/backend/video-upscaling/src/processing/nats_msg.py new file mode 100644 index 0000000..adf8a00 --- /dev/null +++ b/backend/video-upscaling/src/processing/nats_msg.py @@ -0,0 +1,146 @@ +from nats.aio.msg import Msg +from nats.js.kv import KeyValue +from nats.js import JetStreamContext +from shared_core.logging import get_logger +from shared_handler.nats import publisher +from shared_handler.kv import update_job_status +from shared_handler.kv import check_already_processed +from shared_handler.messages import ProcessJobMessage +from shared_handler.messages import UpscaleCompleteMsg +from shared_storage.queries import fetch_video +from shared_storage.queries import upload_video +from core.settings import settings +from processing.video import video_upscale +from processing.video import video_downscale +from src.utils.model_router import select_model +import os +import shutil +import asyncio + +logger = get_logger(settings.SERVICE_NAME) + + +async def process_msg( + js: JetStreamContext, msg_processed_kv: KeyValue, job_status_kv: KeyValue, msg: Msg +) -> None: + """Processes a single video upscale nats message""" + try: + metadata = ProcessJobMessage.model_validate_json(msg.data.decode()) + + if await check_already_processed(msg_processed_kv, metadata.job_id): + logger.debug("job already processed, skipping", job_id=metadata.job_id) + await msg.ack() + return + + await update_job_status( + job_status_kv, metadata.job_id, settings.SERVICE_NAME, settings.SERVICE_NAME + ) + + local_video_path = await asyncio.to_thread( + fetch_video, metadata.storage_url, settings.SERVICE_NAME + ) + filename = os.path.basename(local_video_path) + temp_file_loc = f"../temp_output/{metadata.job_id}/{filename}" + os.makedirs(os.path.dirname(temp_file_loc), exist_ok=True) + + logger.debug( + "fetched unprocessed video", + job_id=metadata.job_id, + saved_to=local_video_path, + ) + + res = select_model(metadata.source_resolution, metadata.target_resolution) + if res is None: + logger.debug( + "downscaling video", + job_id=metadata.job_id, + source_res=metadata.source_resolution, + target_res=metadata.target_resolution, + ) + + # async since its very light ffmpeg subprocess + await asyncio.to_thread( + video_downscale, + local_video_path, + metadata.target_resolution, + temp_file_loc, + ) + logger.debug("downscaled video", job_id=metadata.job_id) + + await _finalize_job( + js, msg_processed_kv, msg, metadata.job_id, temp_file_loc + ) + + return + + logger.debug( + "upscaling video", + job_id=metadata.job_id, + source_res=metadata.source_resolution, + target_res=metadata.target_resolution, + ) + + model_path, resolution_scale = res + logger.debug( + "upscaling with model and resolution", + jobid=metadata, + scale=resolution_scale, + model=model_path, + ) + + loop = asyncio.get_event_loop() + + def on_progress(pct: int) -> None: + asyncio.run_coroutine_threadsafe( + update_job_status( + job_status_kv, + metadata.job_id, + settings.SERVICE_NAME, + settings.SERVICE_NAME, + pct, + ), + loop, + ) + + await asyncio.to_thread( + video_upscale, + local_video_path, + temp_file_loc, + model_path, + resolution_scale, + on_progress, + ) + logger.debug("upscaled video", job_id=metadata.job_id) + + await _finalize_job(js, msg_processed_kv, msg, metadata.job_id, temp_file_loc) + + return + except Exception as e: + logger.error("unexpected error processing job", err=str(e)) + await msg.nak() + + +async def _finalize_job( + js: JetStreamContext, + msg_processed_kv: KeyValue, + msg: Msg, + job_id: str, + temp_file_loc: str, +) -> None: + """shared logic for uploading video file to storage, publish complete msg, updating KV and acking msg""" + storage_url = f"{settings.BASE_STORAGE_URL}/{job_id}/output.mp4/processed" + upload_video(storage_url, job_id, temp_file_loc, settings.SERVICE_NAME) + + await publisher( + js, + UpscaleCompleteMsg(job_id=job_id), + settings.PUB_SUBJECT, + settings.SERVICE_NAME, + ) + + await msg_processed_kv.put(job_id, b"done") + await msg.ack() + + shutil.rmtree(os.path.dirname(temp_file_loc)) + shutil.rmtree(f"../temp/{job_id}", ignore_errors=True) + logger.debug("removed temp dirs", job_id=job_id) diff --git a/backend/video-upscaling/src/processing/video.py b/backend/video-upscaling/src/processing/video.py new file mode 100644 index 0000000..a61763a --- /dev/null +++ b/backend/video-upscaling/src/processing/video.py @@ -0,0 +1,224 @@ +from typing import Optional, Callable +from pathlib import Path +from queue import Queue +from subprocess import Popen +from utils.metrics import log_timing +from utils.model_router import Resolution +from core.settings import settings +from processing.batch import flush_batch +from processing.worker import encode_worker +from processing.load_model import load_model +import time +import threading +import subprocess +import numpy as np + +def extract_video_info(video_path: str) -> tuple[int, int, float, int]: + """ + use ffprobe to extract video information like w, h, and fps of a video + + Args: + video_path: the path to the video we are trying to process + + Returns: + a tuple containing the width, height, fps, and num frames of the video + + Raises: + TypeError if the video_path is not provided + """ + if not video_path: + raise TypeError("Missing video_path input") + + probe = subprocess.run([ + "ffprobe", "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height,r_frame_rate,nb_frames", + "-of", "csv=p=0", + video_path + ], capture_output=True, text=True, check=True) + + w, h, fps_frac, nb_frames = probe.stdout.strip().split(",") + + fps_num, fps_den = fps_frac.split("/") + fps = float(fps_num) / float(fps_den) + + return int(w), int(h), fps, int(nb_frames) + +def recombine_video_audio(video_path: str, output_path: str) -> None: + """ + Use ffmpeg to recombine the no audio upscaled video with the original audio + + Args: + video_path: path to the original video with audio + output_path: the path to save the combined video to + """ + subprocess.run([ + "ffmpeg", "-y", + "-i", "/tmp/upscaled_noaudio.mp4", + "-i", video_path, + "-map", "0:v", "-map", "1:a?", + "-c", "copy", + output_path + ], check=True, stderr=subprocess.DEVNULL) + +def video_decoder(video_path: str) -> Popen[bytes]: + """ + Uses ffmpeg to read the input video file to output raw pixel + data (RGB24) frame-by-frame to stdout. Used to pipe directly into + the model to upscale frames without writing temp png files + + Usage: + decoder.stdout.read(frame_bytes) + + Args: + video_path: path to video to process + + Returns: + decoder instance + """ + # todo: detect cuda and switch between cpu and hwaccel + return subprocess.Popen([ + "ffmpeg", "-hwaccel", "cuda", "-c:v", "h264_cuvid", + "-i", video_path, + "-f", "rawvideo", "-pix_fmt", "rgb24", "-" + ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + +def video_encoder(fps: float, out_w: int, out_h: int, out_path: str) -> Popen[bytes]: + """ + Long running encoder that takes in upscaled frames from encode_worker via stdin + and encodes the frames to the correct resolution and framerate as a compressed + H264 .mp4 video file with no audio + + Args: + fps: the desired fps for the compressed video file + out_w: the desired video width for the compressed video file + out_h: the desired video height for the compressed video file + out_path: the path for the compressed video file to be saved to + + Raises: + ValueError if fps, out_w, or out_h is invalid (negative value) + """ + if fps <= 0: + raise ValueError("fps cant be negative or 0") + if out_w <= 0: + raise ValueError("out_w cant be negative or 0") + if out_h is None or out_h <= 0: + raise ValueError("out_h cant be negative or 0") + + return subprocess.Popen([ + "ffmpeg", "-y", + "-f", "rawvideo", "-pix_fmt", "yuv420p", + "-s", f"{out_w}x{out_h}", + "-r", str(fps), + "-i", "pipe:0", + "-c:v", "libx264", "-crf", "18", + "-preset", "ultrafast", + "-pix_fmt", "yuv420p", + out_path + ], stdin=subprocess.PIPE, stderr=subprocess.DEVNULL) + +def video_downscale(video_path: str, target_res: str, output_path: str) -> None: + """ + Uses ffmpeg to downscale a video to a lower res. Used when the target resolution + is the same as the source resolution or less than the source resolution + + Usage: + decoder.stdout.read(frame_bytes) + + Args: + video_path: path to where the video is fetched and downloaded to from seaweedfs storage + target_res: the resolution to downscale to + output_path: path to where the final downscaled video is saved to + + Raises: + RuntimeError when calling the ffmpeg subprocess fails with an error + """ + try: + tgt_res = Resolution.from_string(target_res) + + subprocess.run([ + "ffmpeg", + "-i", video_path, + "-vf", f"scale=-2:{tgt_res}", + "-c:a", "copy", + output_path + ], check=True) + except subprocess.CalledProcessError as e: + raise RuntimeError(f"ffmpeg downscale failed: {e.stderr.decode()}") from e + + +def video_upscale( + video_path: str, + output_path: str, + model_path: Path, + scale: int, + on_progress: Callable[[int], None] | None = None +) -> None: + """Upscale a video using the model""" + w, h, fps, total_frames = extract_video_info(video_path) + + out_w, out_h = w * scale, h * scale + + upsampler = load_model(model_path, scale) + + decoder = video_decoder(video_path) + encoder = video_encoder(fps, out_w, out_h, "/tmp/upscaled_noaudio.mp4") + + encode_queue: Queue[Optional[bytes]] = Queue(maxsize=4) + + encode_thread = threading.Thread( + target=encode_worker, args=(encode_queue, encoder), daemon=True + ) + encode_thread.start() + + frame_bytes = h * w * 3 + t_read = t_infer = t_enq = 0.0 + n_frames = 0 + n_batches = 0 + pending: list[np.ndarray] = [] + + while True: + t0 = time.perf_counter() + if not decoder.stdout: + break + + raw = decoder.stdout.read(frame_bytes) + t_read += time.perf_counter() - t0 + if len(raw) < frame_bytes: + break + + bgr = np.frombuffer(raw, dtype=np.uint8).reshape(h, w, 3)[:, :, ::-1].copy() + pending.append(bgr) + + if len(pending) == settings.BATCH_SIZE: + dt_infer, dt_enq, n = flush_batch(upsampler, pending, encode_queue) + t_infer += dt_infer + t_enq += dt_enq + n_frames += n + n_batches += 1 + + if on_progress is not None: + pct = min(99, int(n_frames / total_frames * 100)) + on_progress(pct) + + pending.clear() + + if pending: + dt_infer, dt_enq, n = flush_batch(upsampler, pending, encode_queue) + t_infer += dt_infer + t_enq += dt_enq + n_frames += n + n_batches += 1 + + if decoder.stdout: + decoder.stdout.close() + decoder.wait() + encode_queue.put(None) + + t_enc_start = time.perf_counter() + encode_thread.join() + t_enc = time.perf_counter() - t_enc_start + + log_timing(t_read, t_infer, t_enq, t_enc, n_frames, n_batches) + + recombine_video_audio(video_path, output_path) diff --git a/backend/video-upscaling/src/processing/worker.py b/backend/video-upscaling/src/processing/worker.py new file mode 100644 index 0000000..aa26394 --- /dev/null +++ b/backend/video-upscaling/src/processing/worker.py @@ -0,0 +1,26 @@ +from queue import Queue +from typing import Optional +from subprocess import Popen + + +def encode_worker(encode_queue: Queue[Optional[bytes]], encoder: Popen[bytes]) -> None: + """ + runs in a background thread. pulls upscaled frames from encode_queue + and writes them to ffmpeg encoder's stdin for further processing + + Args: + encode_queue: the queue to pull upscaled frames from to write to encoder + encoder: the encoder to write the upscaled frames to + """ + while True: + frame = encode_queue.get() + if frame is None: + break + + if encoder.stdin: + encoder.stdin.write(frame) + + if encoder.stdin: + encoder.stdin.close() + + encoder.wait() diff --git a/backend/video-upscaling/src/service.py b/backend/video-upscaling/src/service.py new file mode 100644 index 0000000..4bad0a3 --- /dev/null +++ b/backend/video-upscaling/src/service.py @@ -0,0 +1,46 @@ +from processing.nats_msg import process_msg +from shared_handler.kv import create_kv +from shared_handler.nats import consumer +from shared_core.logging import get_logger +from shared_handler.kv import connect_kv +from shared_handler.connection import nats_connect +from shared_handler.connection import check_js_stream_exists +from shared_handler.http import start_health_server +from shared_storage.check_health import check_storage_health +from core.settings import settings +import asyncio + +logger = get_logger(settings.SERVICE_NAME) + + +async def start_service() -> None: + """Start the video-upscaling service""" + check_storage_health(settings.SERVICE_NAME) + health_server = start_health_server(settings.HTTP_PORT) + + nc, js = await nats_connect(settings.SERVICE_NAME) + + await check_js_stream_exists(js, settings.SUB_SUBJECT) + await check_js_stream_exists(js, settings.PUB_SUBJECT) + + job_status_kv = await connect_kv(js, "job-status") + msg_processed_kv = await create_kv(js, "upscale-processed") + + try: + await consumer( + js, + msg_processed_kv, + job_status_kv, + settings.SUB_SUBJECT, + settings.SUB_QUEUE_NAME, + settings.SUB_QUEUE_NAME, + process_msg=process_msg, + ) + finally: + health_server.shutdown() + await nc.drain() + + +if __name__ == "__main__": + logger.debug("starting service") + asyncio.run(start_service()) diff --git a/backend/video-upscaling/src/utils/metrics.py b/backend/video-upscaling/src/utils/metrics.py new file mode 100644 index 0000000..71456c0 --- /dev/null +++ b/backend/video-upscaling/src/utils/metrics.py @@ -0,0 +1,32 @@ +# Todo: Replace this with prometheus timing metrics later +from core.settings import settings + + +def log_timing( + t_read: float, + t_infer: float, + t_enq: float, + t_enc: float, + n_frames: int, + n_batches: int, +) -> None: + loop = t_read + t_infer + t_enq + print( + f"\n=== per-phase breakdown ({n_frames} frames, batch={settings.BATCH_SIZE}) ===" + ) + for label, val in [ + ("decode pipe read", t_read), + ("batch infer ", t_infer), + ("encode queue put", t_enq), + ]: + pct = f"{100 * val / loop:5.1f}%" if loop > 0 else " N/A " + mspf = ( + f"{1000 * val / n_frames:5.1f}ms/frame" if n_frames > 0 else " N/A " + ) + print(f" {label} {val:6.2f}s {pct} ({mspf})") + print(f" {'loop total '} {loop:6.2f}s") + print(f" encode thread wait {t_enc:6.2f}s") + print( + f" batches: {n_batches} ({settings.BATCH_SIZE} frames each, last may be partial)" + ) + print() diff --git a/backend/video-upscaling/src/utils/model_router.py b/backend/video-upscaling/src/utils/model_router.py new file mode 100644 index 0000000..f8c2884 --- /dev/null +++ b/backend/video-upscaling/src/utils/model_router.py @@ -0,0 +1,41 @@ +from typing import Optional +from enum import IntEnum +from pathlib import Path + +_BASE = Path(__file__).parent.parent + + +class Resolution(IntEnum): + R_280P = 280 + R_360P = 360 + R_480P = 480 + R_720P = 720 + R_960P = 960 + R_1080P = 1080 + R_1440P = 1440 + + @classmethod + def from_string(cls, s: str) -> "Resolution": + return cls(int(s.rstrip("p"))) + + +def select_model(source_res: str, target_res: str) -> Optional[tuple[Path, int]]: + """""" + src = Resolution.from_string(source_res) + tgt = Resolution.from_string(target_res) + + ratio = tgt / src + + if ratio <= 1: + return None + + if ratio >= 4: + model_path = _BASE / "weights" / "realesr-animevideov3.pth" + resolution_scale = 4 + + return model_path, resolution_scale + else: + model_path = _BASE / "weights" / "RealESRGANv2-animevideo-xsx2.pth" + resolution_scale = 2 + + return model_path, resolution_scale diff --git a/backend/video-upscaling/src/weights/RealESRGANv2-animevideo-xsx2.pth b/backend/video-upscaling/src/weights/RealESRGANv2-animevideo-xsx2.pth new file mode 100644 index 0000000..d9f9d9d Binary files /dev/null and b/backend/video-upscaling/src/weights/RealESRGANv2-animevideo-xsx2.pth differ diff --git a/backend/video-upscaling/src/weights/realesr-animevideov3.pth b/backend/video-upscaling/src/weights/realesr-animevideov3.pth new file mode 100644 index 0000000..a8ffca5 Binary files /dev/null and b/backend/video-upscaling/src/weights/realesr-animevideov3.pth differ diff --git a/backend/video-upscaling/tests/__init__.py b/backend/video-upscaling/tests/__init__.py new file mode 100644 index 0000000..792d600 --- /dev/null +++ b/backend/video-upscaling/tests/__init__.py @@ -0,0 +1 @@ +# diff --git a/backend/video-upscaling/tests/conftest.py b/backend/video-upscaling/tests/conftest.py new file mode 100644 index 0000000..cfb7f2d --- /dev/null +++ b/backend/video-upscaling/tests/conftest.py @@ -0,0 +1,6 @@ +# references to fixture files +pytest_plugins = [ + "tests.fixtures.nats_helpers", + "tests.fixtures.processing_helpers", + "tests.fixtures.service_helpers", +] diff --git a/backend/video-upscaling/tests/fixtures/nats_helpers.py b/backend/video-upscaling/tests/fixtures/nats_helpers.py new file mode 100644 index 0000000..6f5384b --- /dev/null +++ b/backend/video-upscaling/tests/fixtures/nats_helpers.py @@ -0,0 +1,118 @@ +from typing import Any, AsyncGenerator, Generator +from unittest.mock import patch, AsyncMock, MagicMock +from nats.js import JetStreamContext +from nats.js.api import KeyValueConfig +from nats.js.errors import KeyNotFoundError +from nats.js.kv import KeyValue +from testcontainers.nats import NatsContainer +from src.core.settings import settings +import nats # type: ignore[import-untyped] +import pytest +import pytest_asyncio + + +@pytest.fixture +def nats_msg_patches() -> Generator[dict[str, Any], Any, None]: + """Patches all external dependencies used by process_msg / _finalize_job.""" + with ( + patch( + "src.processing.nats_msg.check_already_processed", new_callable=AsyncMock + ) as mock_check, + patch( + "src.processing.nats_msg.update_job_status", new_callable=AsyncMock + ) as mock_update_status, + patch( + "src.processing.nats_msg.fetch_video", return_value="/tmp/job-123/video.mp4" + ) as mock_fetch, + patch("src.processing.nats_msg.select_model") as mock_select, + patch("src.processing.nats_msg.video_upscale") as mock_upscale, + patch("src.processing.nats_msg.video_downscale") as mock_downscale, + patch("src.processing.nats_msg.upload_video") as mock_upload, + patch("src.processing.nats_msg.publisher", new_callable=AsyncMock) as mock_pub, + patch("src.processing.nats_msg.shutil.rmtree") as mock_rmtree, + patch("src.processing.nats_msg.os.makedirs") as _, + patch( + "src.processing.nats_msg.asyncio.to_thread", + side_effect=lambda fn, *args, **kwargs: fn(*args, **kwargs), + ) as _, + ): + mock_check.return_value = False + yield { + "check": mock_check, + "update_status": mock_update_status, + "fetch": mock_fetch, + "select": mock_select, + "upscale": mock_upscale, + "downscale": mock_downscale, + "upload": mock_upload, + "pub": mock_pub, + "rmtree": mock_rmtree, + } + + +@pytest.fixture +def mock_nats() -> tuple[MagicMock, MagicMock]: + mock_js = MagicMock() + mock_js.find_stream_name_by_subject = AsyncMock() + mock_js.create_key_value = AsyncMock() + mock_js.key_value = AsyncMock() + mock_nc = MagicMock() + mock_nc.drain = AsyncMock() + return mock_nc, mock_js + + +@pytest.fixture(scope="session") +def nats_url() -> Any: + with NatsContainer(jetstream=True) as container: + yield container.nats_uri() + + +@pytest_asyncio.fixture +async def js_context( + nats_url: str, +) -> AsyncGenerator[tuple[Any, JetStreamContext], None]: + nc = await nats.connect(nats_url) + js = nc.jetstream() + try: + await js.delete_stream("videos") + except Exception: + pass + await js.add_stream( + name="videos", + subjects=[settings.SUB_SUBJECT, settings.PUB_SUBJECT], + ) + await js.create_key_value(config=KeyValueConfig(bucket="job-status")) + yield nc, js + await nc.close() + + +@pytest_asyncio.fixture +async def patched_start_service( + js_context: tuple[Any, JetStreamContext], +) -> AsyncGenerator[tuple[Any, JetStreamContext], None]: + nc, js = js_context + + mock_kv = MagicMock(spec=KeyValue) + mock_kv.get = AsyncMock(side_effect=KeyNotFoundError()) + mock_kv.put = AsyncMock() + + with ( + patch("src.service.check_storage_health"), + patch("src.service.start_health_server"), + patch("src.service.nats_connect", return_value=(nc, js)), + patch("src.service.connect_kv", new_callable=AsyncMock), + patch("src.service.create_kv", return_value=mock_kv), + ): + yield nc, js + + +@pytest.fixture +def spy_drain(js_context: tuple[Any, JetStreamContext]) -> tuple[Any, list[bool]]: + nc, _ = js_context + called: list[bool] = [] + + async def _spy() -> None: + called.append(True) + + nc.drain = _spy + return nc, called diff --git a/backend/video-upscaling/tests/fixtures/processing_helpers.py b/backend/video-upscaling/tests/fixtures/processing_helpers.py new file mode 100644 index 0000000..6cb74de --- /dev/null +++ b/backend/video-upscaling/tests/fixtures/processing_helpers.py @@ -0,0 +1,112 @@ +from pathlib import Path +from typing import Any +from typing import Generator +from unittest.mock import patch +from unittest.mock import MagicMock +from processing.video import recombine_video_audio +import pytest +import subprocess +import numpy as np + + +TEST_VIDEO = Path(__file__).parent / "testvideo.mp4" + + +@pytest.fixture(scope="module") +def one_frame_video(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Extract a single frame from testvideo as a 1-frame mp4.""" + out = tmp_path_factory.mktemp("frames") / "one_frame.mp4" + subprocess.run( + ["ffmpeg", "-y", "-i", str(TEST_VIDEO), "-frames:v", "1", str(out)], + check=True, + stderr=subprocess.DEVNULL, + ) + return out + + +@pytest.fixture() +def recombined_video(one_frame_video: Path, tmp_path: Path) -> Path: + """Place 1-frame clip at the hardcoded noaudio path, recombine with original audio.""" + subprocess.run( + ["ffmpeg", "-y", "-i", str(one_frame_video), "/tmp/upscaled_noaudio.mp4"], + check=True, + stderr=subprocess.DEVNULL, + ) + output = tmp_path / "recombined.mp4" + recombine_video_audio(str(TEST_VIDEO), str(output)) + return output + + +def make_fake_decoder(frames: list[np.ndarray]) -> MagicMock: + """Build a mock decoder whose stdout yields the given raw bgr frames then EOF.""" + stdout = MagicMock() + stdout.read.side_effect = [f.tobytes() for f in frames] + [b""] + decoder = MagicMock() + decoder.stdout = stdout + return decoder + + +@pytest.fixture +def mock_deps() -> Generator[dict[str, Any], Any, None]: + mock_net = MagicMock() + mock_net.half.return_value = mock_net + + mock_upsampler = MagicMock() + mock_upsampler.model = mock_net + + mock_compiled = MagicMock() + + with ( + patch( + "src.processing.load_model.SRVGGNetCompact", return_value=mock_net + ) as mock_srvgg, + patch( + "src.processing.load_model.RealESRGANer", return_value=mock_upsampler + ) as mock_realesrgan, + patch( + "src.processing.load_model.torch.compile", return_value=mock_compiled + ) as mock_compile, + ): + yield { + "srvgg": mock_srvgg, + "realesrgan": mock_realesrgan, + "compile": mock_compile, + "net": mock_net, + "upsampler": mock_upsampler, + } + + +@pytest.fixture +def video_upscale_patches() -> Generator[dict[str, Any], Any, None]: + """Patches all external dependencies used by video_upscale.""" + mock_thread = MagicMock() + mock_encoder = MagicMock() + + with ( + patch( + "src.processing.video.extract_video_info", return_value=(64, 64, 24.0, 0) + ) as mock_info, + patch("src.processing.video.load_model", return_value=MagicMock()) as mock_load, + patch("src.processing.video.video_decoder") as mock_decoder, + patch( + "src.processing.video.video_encoder", return_value=mock_encoder + ) as mock_enc, + patch( + "src.processing.video.flush_batch", return_value=(0.0, 0.0, 0) + ) as mock_flush, + patch("src.processing.video.encode_worker") as mock_worker, + patch("src.processing.video.threading.Thread", return_value=mock_thread) as _, + patch("src.processing.video.recombine_video_audio") as mock_recombine, + patch("src.processing.video.settings") as mock_settings, + ): + mock_settings.BATCH_SIZE = 4 + yield { + "info": mock_info, + "load": mock_load, + "decoder": mock_decoder, + "encoder": mock_enc, + "flush": mock_flush, + "worker": mock_worker, + "recombine": mock_recombine, + "settings": mock_settings, + } diff --git a/backend/video-upscaling/tests/fixtures/service_helpers.py b/backend/video-upscaling/tests/fixtures/service_helpers.py new file mode 100644 index 0000000..9f90968 --- /dev/null +++ b/backend/video-upscaling/tests/fixtures/service_helpers.py @@ -0,0 +1,85 @@ +from pathlib import Path +from typing import Any +from typing import Generator +from unittest.mock import patch +from unittest.mock import MagicMock +from testcontainers.core.container import DockerContainer +import time +import pytest +import requests +import subprocess + +TEST_VIDEO = Path(__file__).parent.parent / "fixtures" / "testvideo.mp4" + + +def _wait_for_seaweedfs( + host: str, master_port: int, filer_port: int, timeout: int = 60 +) -> None: + for url in [ + f"http://{host}:{master_port}/dir/status", + f"http://{host}:{filer_port}/", + ]: + deadline = time.time() + timeout + while time.time() < deadline: + try: + if requests.get(url, timeout=2).status_code < 500: + break + except Exception: + pass + time.sleep(1) + else: + raise TimeoutError(f"SeaweedFS not ready at {url}") + + +@pytest.fixture(scope="session") +def seaweedfs_url() -> Generator[str, None, None]: + with ( + DockerContainer("chrislusf/seaweedfs") + .with_command("server -dir=/data -master.port=9333 -volume.port=8080 -filer") + .with_exposed_ports(9333, 8888) + ) as container: + host = container.get_container_host_ip() + master_port = int(container.get_exposed_port(9333)) + filer_port = int(container.get_exposed_port(8888)) + _wait_for_seaweedfs(host, master_port, filer_port) + yield f"http://{host}:{filer_port}" + + +@pytest.fixture(scope="session") +def uploaded_test_video( + seaweedfs_url: str, tmp_path_factory: pytest.TempPathFactory +) -> str: + """Generates a tiny 1-frame mp4, uploads to SeaweedFS, returns the storage URL.""" + tiny = tmp_path_factory.mktemp("video") / "tiny.mp4" + subprocess.run( + [ + "ffmpeg", + "-y", + "-f", + "lavfi", + "-i", + "color=c=blue:size=128x72:rate=1", + "-frames:v", + "1", + str(tiny), + ], + check=True, + stderr=subprocess.DEVNULL, + ) + storage_url = f"{seaweedfs_url}/test-job/tiny.mp4" + with open(tiny, "rb") as f: + requests.put( + storage_url, data=f, headers={"Content-Type": "application/octet-stream"} + ).raise_for_status() + return storage_url + + +@pytest.fixture +def service_patches(mock_nats: tuple[MagicMock, MagicMock]) -> Any: + mock_nc, mock_js = mock_nats + with ( + patch("src.service.check_storage_health"), + patch("src.service.start_health_server"), + patch("src.service.nats_connect", return_value=(mock_nc, mock_js)), + ): + yield mock_nc, mock_js diff --git a/backend/video-upscaling/tests/fixtures/testvideo.mp4 b/backend/video-upscaling/tests/fixtures/testvideo.mp4 new file mode 100644 index 0000000..6aca024 Binary files /dev/null and b/backend/video-upscaling/tests/fixtures/testvideo.mp4 differ diff --git a/backend/video-upscaling/tests/integration/test_load_model.py b/backend/video-upscaling/tests/integration/test_load_model.py new file mode 100644 index 0000000..e224306 --- /dev/null +++ b/backend/video-upscaling/tests/integration/test_load_model.py @@ -0,0 +1,68 @@ +from pathlib import Path +from realesrgan import RealESRGANer +from src.processing.load_model import load_model +import pytest +import torch + +WEIGHTS_DIR = Path(__file__).parent.parent.parent / "src" / "weights" + +requires_cuda = pytest.mark.skipif( + not torch.cuda.is_available(), reason="CUDA not available" +) + + +@pytest.mark.parametrize( + "filename,scale", + [ + ("realesr-animevideov3.pth", 4), + ("RealESRGANv2-animevideo-xsx2.pth", 2), + ], +) +@requires_cuda +def test_load_model_returns_realesrganer(filename: str, scale: int) -> None: + result = load_model(WEIGHTS_DIR / filename, scale=scale) + + assert isinstance(result, RealESRGANer) + + +@pytest.mark.parametrize( + "filename,scale", + [ + ("realesr-animevideov3.pth", 4), + ("RealESRGANv2-animevideo-xsx2.pth", 2), + ], +) +@requires_cuda +def test_load_model_scale_matches(filename: str, scale: int) -> None: + result = load_model(WEIGHTS_DIR / filename, scale=scale) + + assert result.scale == scale + + +@pytest.mark.parametrize( + "filename,scale", + [ + ("realesr-animevideov3.pth", 4), + ("RealESRGANv2-animevideo-xsx2.pth", 2), + ], +) +@requires_cuda +def test_load_model_is_half_precision(filename: str, scale: int) -> None: + result = load_model(WEIGHTS_DIR / filename, scale=scale) + + param = next(result.model.parameters()) + assert param.dtype == torch.float16 + + +@pytest.mark.parametrize( + "filename,scale", + [ + ("realesr-animevideov3.pth", 4), + ("RealESRGANv2-animevideo-xsx2.pth", 2), + ], +) +@requires_cuda +def test_load_model_tile_disabled(filename: str, scale: int) -> None: + result = load_model(WEIGHTS_DIR / filename, scale=scale) + + assert result.tile_size == 0 diff --git a/backend/video-upscaling/tests/integration/test_service.py b/backend/video-upscaling/tests/integration/test_service.py new file mode 100644 index 0000000..2c7dd1b --- /dev/null +++ b/backend/video-upscaling/tests/integration/test_service.py @@ -0,0 +1,228 @@ +from typing import Any +from unittest.mock import patch +from unittest.mock import AsyncMock +from nats.js import JetStreamContext +from shared_storage import queries +from src.service import start_service +from src.core.settings import settings +import json +import uuid +import pytest +import asyncio + + +def _make_payload( + job_id: str, + storage_url: str, + source_resolution: str = "720p", + target_resolution: str = "480p", +) -> bytes: + return json.dumps( + { + "job_id": job_id, + "storage_url": storage_url, + "source_resolution": source_resolution, + "target_resolution": target_resolution, + } + ).encode() + + +async def _run_service_until_processed( + nc: Any, + payload: bytes, + queue_name: str, + done: asyncio.Event | None = None, + timeout: float = 30.0, +) -> None: + with patch("src.service.settings.SUB_QUEUE_NAME", queue_name): + nc.drain = AsyncMock() + task = asyncio.create_task(start_service()) + await nc.publish(settings.SUB_SUBJECT, payload) + if done is not None: + await asyncio.wait_for(done.wait(), timeout=timeout) + await asyncio.sleep(0.1) # let NATS callbacks fire + else: + await asyncio.sleep(timeout) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +@pytest.mark.asyncio +async def test_full_job_publishes_upscale_complete_msg( + patched_start_service: tuple[Any, JetStreamContext], + uploaded_test_video: str, + seaweedfs_url: str, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + """Full flow: message published -> video fetched from SeaweedFS -> downscaled -> UpscaleCompleteMsg on downstream subject.""" + monkeypatch.setattr(queries, "TEMP_DIR", str(tmp_path)) + monkeypatch.setattr("processing.nats_msg.settings.BASE_STORAGE_URL", seaweedfs_url) + + nc, js = patched_start_service + job_id = str(uuid.uuid4()) + received: list[Any] = [] + + done = asyncio.Event() + + async def capture(msg: Any) -> None: + received.append(json.loads(msg.data.decode())) + done.set() + + sub = await nc.subscribe(settings.PUB_SUBJECT, cb=capture) + + await _run_service_until_processed( + nc, + _make_payload( + job_id, + uploaded_test_video, + source_resolution="720p", + target_resolution="480p", + ), + "test-full-flow-worker", + done=done, + ) + + await sub.unsubscribe() + assert any(m.get("job_id") == job_id for m in received) + + +@pytest.mark.asyncio +async def test_full_job_uploads_output_to_storage( + patched_start_service: tuple[Any, JetStreamContext], + uploaded_test_video: str, + seaweedfs_url: str, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + """After processing, output video is PUT to SeaweedFS at the expected URL.""" + monkeypatch.setattr(queries, "TEMP_DIR", str(tmp_path)) + monkeypatch.setattr("processing.nats_msg.settings.BASE_STORAGE_URL", seaweedfs_url) + + nc, js = patched_start_service + job_id = str(uuid.uuid4()) + uploaded_urls: list[str] = [] + + original_upload = __import__( + "shared_storage.queries", fromlist=["upload_video"] + ).upload_video + done = asyncio.Event() + + def spy_upload(url: str, *args: Any, **kwargs: Any) -> str: + uploaded_urls.append(url) + result = original_upload(url, *args, **kwargs) + done.set() + return result + + with patch("processing.nats_msg.upload_video", side_effect=spy_upload): + await _run_service_until_processed( + nc, + _make_payload( + job_id, + uploaded_test_video, + source_resolution="720p", + target_resolution="480p", + ), + "test-upload-worker", + done=done, + ) + + expected_url = f"{seaweedfs_url}/{job_id}/output.mp4/processed" + assert any(expected_url in url for url in uploaded_urls) + + +# --------------------------------------------------------------------------- +# startup failures +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_raises_when_pub_stream_not_found( + patched_start_service: tuple[Any, JetStreamContext], + monkeypatch: Any, +) -> None: + nc, _ = patched_start_service + monkeypatch.setattr("src.service.settings.PUB_SUBJECT", "nonexistent.subject.xyz") + nc.drain = AsyncMock() + + with pytest.raises(RuntimeError, match="No stream found"): + await start_service() + + +@pytest.mark.asyncio +async def test_raises_when_sub_stream_not_found( + patched_start_service: tuple[Any, JetStreamContext], + monkeypatch: Any, +) -> None: + nc, _ = patched_start_service + monkeypatch.setattr("src.service.settings.SUB_SUBJECT", "nonexistent.subject.xyz") + nc.drain = AsyncMock() + + with pytest.raises(RuntimeError, match="No stream found"): + await start_service() + + +# --------------------------------------------------------------------------- +# finally block – drain always called +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_drain_called_on_consumer_failure( + patched_start_service: tuple[Any, JetStreamContext], + spy_drain: tuple[Any, list[bool]], +) -> None: + _, called = spy_drain + + with ( + patch("src.service.consumer", side_effect=RuntimeError("boom")), + pytest.raises(RuntimeError), + ): + await start_service() + + assert called + + +@pytest.mark.asyncio +async def test_drain_called_on_cancellation( + patched_start_service: tuple[Any, JetStreamContext], + spy_drain: tuple[Any, list[bool]], +) -> None: + _, called = spy_drain + + async def _hang(*_: Any, **__: Any) -> None: + await asyncio.Event().wait() + + with patch("src.service.consumer", side_effect=_hang): + task = asyncio.create_task(start_service()) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert called + + +# --------------------------------------------------------------------------- +# storage health check blocks startup +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_raises_before_nats_when_storage_unreachable(monkeypatch: Any) -> None: + monkeypatch.setattr( + "shared_storage.check_health.settings.BASE_STORAGE_URL", "http://localhost:1" + ) + + with ( + patch("src.service.nats_connect") as mock_nats_connect, + pytest.raises(Exception), + ): + await start_service() + + mock_nats_connect.assert_not_called() diff --git a/backend/video-upscaling/tests/integration/test_video.py b/backend/video-upscaling/tests/integration/test_video.py new file mode 100644 index 0000000..5800f8f --- /dev/null +++ b/backend/video-upscaling/tests/integration/test_video.py @@ -0,0 +1,138 @@ +from pathlib import Path +from src.processing.video import video_decoder +from src.processing.video import video_upscale +from src.processing.video import video_downscale +from src.processing.video import extract_video_info +from tests.fixtures.processing_helpers import TEST_VIDEO +import torch +import pytest +import subprocess + + +WEIGHTS_DIR = Path(__file__).parent.parent.parent / "src" / "weights" + +requires_cuda = pytest.mark.skipif( + not torch.cuda.is_available(), reason="CUDA not available" +) + + +def test_extract_video_info_returns_correct_info() -> None: + w, h, fps, nb_frames = extract_video_info(str(TEST_VIDEO)) + + assert w == 1280 + assert h == 720 + assert abs(fps - 23.976) < 0.01 + assert nb_frames == 360 + + +@pytest.mark.parametrize("target_res", ["480p", "360p"]) +def test_video_downscale_produces_output_file(target_res: str, tmp_path: Path) -> None: + output = str(tmp_path / f"out_{target_res}.mp4") + video_downscale(str(TEST_VIDEO), target_res, output) + + assert Path(output).exists() + assert Path(output).stat().st_size > 0 + + +@pytest.mark.parametrize("target_res,expected_h", [("480p", 480), ("360p", 360)]) +def test_video_downscale_output_has_correct_resolution( + target_res: str, expected_h: int, tmp_path: Path +) -> None: + output = str(tmp_path / f"out_{target_res}.mp4") + video_downscale(str(TEST_VIDEO), target_res, output) + + _, h, _, _ = extract_video_info(output) + + assert h == expected_h + + +def test_video_decoder_reads_correct_frame_size(one_frame_video: Path) -> None: + w, h, _, _ = extract_video_info(str(one_frame_video)) + frame_bytes = w * h * 3 # rgb24 + + decoder = video_decoder(str(one_frame_video)) + assert decoder.stdout is not None + + raw = decoder.stdout.read(frame_bytes) + decoder.stdout.close() + decoder.wait() + + assert len(raw) == frame_bytes + + +def test_video_decoder_returns_non_empty_frame(one_frame_video: Path) -> None: + w, h, _, _ = extract_video_info(str(one_frame_video)) + frame_bytes = w * h * 3 + + decoder = video_decoder(str(one_frame_video)) + assert decoder.stdout is not None + + raw = decoder.stdout.read(frame_bytes) + decoder.stdout.close() + decoder.wait() + + assert any(b != 0 for b in raw) + + +def test_recombine_video_audio_produces_output_file(recombined_video: Path) -> None: + assert recombined_video.exists() + assert recombined_video.stat().st_size > 0 + + +def test_recombine_video_audio_output_has_audio_stream(recombined_video: Path) -> None: + probe = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "a", + "-show_entries", + "stream=codec_type", + "-of", + "csv=p=0", + str(recombined_video), + ], + capture_output=True, + text=True, + ) + assert "audio" in probe.stdout + + +@requires_cuda +@pytest.mark.parametrize( + "filename,scale", + [ + ("realesr-animevideov3.pth", 4), + ("RealESRGANv2-animevideo-xsx2.pth", 2), + ], +) +def test_video_upscale_produces_output_file( + one_frame_video: Path, tmp_path: Path, filename: str, scale: int +) -> None: + output = str(tmp_path / f"upscaled_{scale}x.mp4") + video_upscale(str(one_frame_video), output, WEIGHTS_DIR / filename, scale) + + assert Path(output).exists() + assert Path(output).stat().st_size > 0 + + +@requires_cuda +@pytest.mark.parametrize( + "filename,scale", + [ + ("realesr-animevideov3.pth", 4), + ("RealESRGANv2-animevideo-xsx2.pth", 2), + ], +) +def test_video_upscale_output_has_correct_resolution( + one_frame_video: Path, tmp_path: Path, filename: str, scale: int +) -> None: + src_w, src_h, _, _ = extract_video_info(str(one_frame_video)) + output = str(tmp_path / f"upscaled_{scale}x.mp4") + + video_upscale(str(one_frame_video), output, WEIGHTS_DIR / filename, scale) + + out_w, out_h, _, _ = extract_video_info(output) + assert out_w == src_w * scale + assert out_h == src_h * scale diff --git a/backend/video-upscaling/tests/unit/test_batch.py b/backend/video-upscaling/tests/unit/test_batch.py new file mode 100644 index 0000000..4b1f47e --- /dev/null +++ b/backend/video-upscaling/tests/unit/test_batch.py @@ -0,0 +1,185 @@ +from contextlib import contextmanager +from queue import Queue +from typing import Generator, Optional +from unittest.mock import MagicMock, patch +import numpy as np +import pytest +import torch + + +def _make_bgr_frame(r: float, g: float, b: float, h: int = 4, w: int = 4) -> np.ndarray: + """Return a solid-colour BGR frame as uint8 in [0, 255].""" + frame = np.zeros((h, w, 3), dtype=np.uint8) + frame[:, :, 0] = int(b * 255) # B channel + frame[:, :, 1] = int(g * 255) # G channel + frame[:, :, 2] = int(r * 255) # R channel + return frame + + +def _model_passthrough(batch: torch.Tensor) -> torch.Tensor: + """Fake model that returns the input unchanged (already in [0,1] RGB fp16).""" + return batch.float() # autocast may produce fp16; cast to float for math + + +@contextmanager +def _patch_cuda() -> Generator[None, None, None]: + """ + Patch out the two CUDA-specific calls in _infer_batch so tests run on CPU: + - torch.Tensor.cuda → returns self + - torch.autocast → no-op context manager + """ + + @contextmanager + def _fake_autocast(**_kwargs: object) -> Generator[None, None, None]: + yield + + with ( + patch.object(torch.Tensor, "cuda", lambda self: self), + patch("torch.autocast", _fake_autocast), + ): + yield + + +class TestInferBatch: + def _run(self, frames: list[np.ndarray]) -> list[bytes]: + from src.processing.batch import _infer_batch + + with _patch_cuda(): + return _infer_batch(_model_passthrough, frames) # type: ignore[arg-type] + + def test_returns_one_bytes_object_per_frame(self) -> None: + frames = [_make_bgr_frame(1, 0, 0)] * 3 + results = self._run(frames) + assert len(results) == 3 + assert all(isinstance(r, bytes) for r in results) + + def test_output_length_matches_yuv420p(self) -> None: + h, w = 4, 4 + frame = _make_bgr_frame(1, 0, 0, h, w) + results = self._run([frame]) + # YUV420p: Y plane (h*w) + U plane (h/2 * w/2) + V plane (h/2 * w/2) + expected = h * w + (h // 2) * (w // 2) + (h // 2) * (w // 2) + assert len(results[0]) == expected + + @pytest.mark.parametrize( + "name,rgb,expected_y,expected_u,expected_v", + [ + # ground-truth values computed from the BT.601 coefficients in batch.py + ("black", (0.0, 0.0, 0.0), 16, 128, 128), + ("white", (1.0, 1.0, 1.0), 235, 128, 128), + ("red", (1.0, 0.0, 0.0), 81, 90, 240), + ("green", (0.0, 1.0, 0.0), 144, 53, 34), + ], + ) + def test_yuv_values_match_bt601( + self, + name: str, + rgb: tuple[float, float, float], + expected_y: int, + expected_u: int, + expected_v: int, + ) -> None: + h, w = 4, 4 + r, g, b = rgb + frame = _make_bgr_frame(r, g, b, h, w) + result = self._run([frame])[0] + + raw = np.frombuffer(result, dtype=np.uint8) + y_plane = raw[: h * w].reshape(h, w) + u_plane = raw[h * w : h * w + (h // 2) * (w // 2)].reshape(h // 2, w // 2) + v_plane = raw[h * w + (h // 2) * (w // 2) :].reshape(h // 2, w // 2) + + # Allow ±2 for fp16 rounding in the model passthrough + assert abs(int(y_plane[0, 0]) - expected_y) <= 2, f"{name} Y mismatch" + assert abs(int(u_plane[0, 0]) - expected_u) <= 2, f"{name} U mismatch" + assert abs(int(v_plane[0, 0]) - expected_v) <= 2, f"{name} V mismatch" + + def test_y_plane_is_clamped_to_16_235(self) -> None: + # Black should hit the Y floor of 16 (not 0) + frame = _make_bgr_frame(0.0, 0.0, 0.0) + result = self._run([frame])[0] + h, w = frame.shape[:2] + y_plane = np.frombuffer(result[: h * w], dtype=np.uint8) + assert np.all(y_plane >= 16) + assert np.all(y_plane <= 235) + + def test_uv_planes_are_clamped_to_16_240(self) -> None: + h, w = 4, 4 + frame = _make_bgr_frame( + 1.0, 0.0, 0.0, h, w + ) # red → U near floor, V near ceiling + result = self._run([frame])[0] + uv = np.frombuffer(result[h * w :], dtype=np.uint8) + assert np.all(uv >= 16) + assert np.all(uv <= 240) + + def test_multiple_frames_are_independent(self) -> None: + red_frame = _make_bgr_frame(1.0, 0.0, 0.0) + green_frame = _make_bgr_frame(0.0, 1.0, 0.0) + results = self._run([red_frame, green_frame]) + + h, w = red_frame.shape[:2] + y_red = np.frombuffer(results[0][: h * w], dtype=np.uint8) + y_green = np.frombuffer(results[1][: h * w], dtype=np.uint8) + + assert y_red[0] != y_green[0] + + +class TestFlushBatch: + def _run_flush( + self, + frames: list[np.ndarray], + infer_results: list[bytes], + ) -> tuple[float, float, int]: + from src.processing.batch import flush_batch + + mock_upsampler = MagicMock() + encode_queue: Queue[Optional[bytes]] = Queue() + + with patch("src.processing.batch._infer_batch", return_value=infer_results): + timing = flush_batch(mock_upsampler, frames, encode_queue) + + self._queue = encode_queue + return timing + + def test_returns_three_element_tuple(self) -> None: + result = self._run_flush([_make_bgr_frame(1, 0, 0)], [b"frame"]) + assert len(result) == 3 + + def test_frame_count_matches_input(self) -> None: + frames = [_make_bgr_frame(1, 0, 0)] * 5 + _, _, count = self._run_flush(frames, [b"x"] * 5) + assert count == 5 + + def test_all_results_enqueued(self) -> None: + fake_results = [b"a", b"b", b"c"] + self._run_flush([MagicMock()] * 3, fake_results) + queued = [self._queue.get_nowait() for _ in range(3)] + assert queued == fake_results + + def test_queue_is_empty_after_flush(self) -> None: + self._run_flush([MagicMock()], [b"frame"]) + self._queue.get_nowait() # drain the one frame + assert self._queue.empty() + + def test_inference_time_is_non_negative(self) -> None: + infer_time, _, _ = self._run_flush([MagicMock()], [b"x"]) + assert infer_time >= 0 + + def test_enqueue_time_is_non_negative(self) -> None: + _, enqueue_time, _ = self._run_flush([MagicMock()], [b"x"]) + assert enqueue_time >= 0 + + def test_infer_batch_called_with_model_and_frames(self) -> None: + from src.processing.batch import flush_batch + + mock_upsampler = MagicMock() + frames = [_make_bgr_frame(1, 0, 0)] + encode_queue: Queue[Optional[bytes]] = Queue() + + with patch( + "src.processing.batch._infer_batch", return_value=[b"x"] + ) as mock_infer: + flush_batch(mock_upsampler, frames, encode_queue) + + mock_infer.assert_called_once_with(mock_upsampler.model, frames) diff --git a/backend/video-upscaling/tests/unit/test_load_model.py b/backend/video-upscaling/tests/unit/test_load_model.py new file mode 100644 index 0000000..7a38f56 --- /dev/null +++ b/backend/video-upscaling/tests/unit/test_load_model.py @@ -0,0 +1,69 @@ +from typing import Any +from pathlib import Path +from src.processing.load_model import load_model +import pytest + + +@pytest.mark.parametrize("scale", [1, 3, 8]) +def test_invalid_scale_raises(mock_deps: dict[str, Any], scale: int) -> None: + with pytest.raises(ValueError, match="scale must be either 2 or 4"): + load_model(Path("model.pth"), scale=scale) + + +@pytest.mark.parametrize("scale", [2, 4]) +def test_valid_scale_does_not_raise(mock_deps: dict[str, Any], scale: int) -> None: + load_model(Path("model.pth"), scale=scale) + + +@pytest.mark.parametrize("scale", [2, 4]) +def test_srvgg_constructed_with_correct_params( + mock_deps: dict[str, Any], scale: int +) -> None: + load_model(Path("model.pth"), scale=scale) + + mock_deps["srvgg"].assert_called_once_with( + num_in_ch=3, + num_out_ch=3, + num_feat=64, + num_conv=16, + upscale=scale, + act_type="prelu", + ) + + +@pytest.mark.parametrize("scale", [2, 4]) +def test_realesrganer_constructed_with_correct_params( + mock_deps: dict[str, Any], scale: int +) -> None: + model_path = Path("/some/path/model.pth") + load_model(model_path, scale=scale) + + mock_deps["realesrgan"].assert_called_once_with( + scale=scale, + model_path=str(model_path), + model=mock_deps["net"], + tile=0, + tile_pad=10, + pre_pad=0, + device="cuda", + ) + + +def test_model_converted_to_half_precision(mock_deps: dict[str, Any]) -> None: + load_model(Path("model.pth"), scale=2) + + mock_deps["net"].half.assert_called_once() + + +def test_torch_compile_called_with_reduce_overhead(mock_deps: dict[str, Any]) -> None: + load_model(Path("model.pth"), scale=2) + + mock_deps["compile"].assert_called_once_with( + mock_deps["net"], mode="reduce-overhead" + ) + + +def test_returns_upsampler(mock_deps: dict[str, Any]) -> None: + result = load_model(Path("model.pth"), scale=2) + + assert result is mock_deps["upsampler"] diff --git a/backend/video-upscaling/tests/unit/test_model_router.py b/backend/video-upscaling/tests/unit/test_model_router.py new file mode 100644 index 0000000..0358e7b --- /dev/null +++ b/backend/video-upscaling/tests/unit/test_model_router.py @@ -0,0 +1,87 @@ +from src.utils.model_router import Resolution +from src.utils.model_router import select_model +import pytest + + +@pytest.mark.parametrize( + "s,expected", + [ + ("280p", Resolution.R_280P), + ("360p", Resolution.R_360P), + ("480p", Resolution.R_480P), + ("720p", Resolution.R_720P), + ("960p", Resolution.R_960P), + ("1080p", Resolution.R_1080P), + ("1440p", Resolution.R_1440P), + ], +) +def test_from_string_parses_valid_resolution(s: str, expected: Resolution) -> None: + assert Resolution.from_string(s) == expected + + +@pytest.mark.parametrize("s", ["144p", "4k", "", "abcp"]) +def test_from_string_raises_for_unknown_resolution(s: str) -> None: + with pytest.raises(ValueError): + Resolution.from_string(s) + + +@pytest.mark.parametrize( + "source,target", + [ + ("1080p", "480p"), + ("720p", "360p"), + ("480p", "480p"), # same resolution + ], +) +def test_select_model_returns_none_when_ratio_lte_1(source: str, target: str) -> None: + assert select_model(source, target) is None + + +@pytest.mark.parametrize( + "source,target", + [ + ("480p", "960p"), # ratio = 2 + ("480p", "1080p"), # ratio = 2.25 + ("720p", "1440p"), # ratio = 2 + ], +) +def test_select_model_returns_x2_model_for_ratio_lt_4(source: str, target: str) -> None: + result = select_model(source, target) + + assert result is not None + model_path, scale = result + assert scale == 2 + assert model_path.name == "RealESRGANv2-animevideo-xsx2.pth" + + +@pytest.mark.parametrize( + "source,target", + [ + ("360p", "1440p"), # ratio = 4 + ("280p", "1440p"), # ratio > 4 + ], +) +def test_select_model_returns_x4_model_for_ratio_gte_4( + source: str, target: str +) -> None: + result = select_model(source, target) + + assert result is not None + model_path, scale = result + assert scale == 4 + assert model_path.name == "realesr-animevideov3.pth" + + +@pytest.mark.parametrize( + "source,target", + [ + ("480p", "960p"), # x2 + ("360p", "1440p"), # x4 + ], +) +def test_select_model_returned_path_exists(source: str, target: str) -> None: + result = select_model(source, target) + + assert result is not None + model_path, _ = result + assert model_path.exists() diff --git a/backend/video-upscaling/tests/unit/test_nats_msg.py b/backend/video-upscaling/tests/unit/test_nats_msg.py new file mode 100644 index 0000000..a7671f7 --- /dev/null +++ b/backend/video-upscaling/tests/unit/test_nats_msg.py @@ -0,0 +1,223 @@ +from typing import Any +from pathlib import Path +from unittest.mock import ANY +from unittest.mock import AsyncMock +from src.core.settings import settings +from src.processing.nats_msg import process_msg +from src.processing.nats_msg import _finalize_job +from shared_handler.messages import ProcessJobMessage +from shared_handler.messages import UpscaleCompleteMsg +import pytest + + +def make_msg( + job_id: str = "job-123", + storage_url: str = "http://storage/video.mp4", + source_resolution: str = "480p", + target_resolution: str = "1080p", +) -> AsyncMock: + """Build a mock NATS Msg with a valid ProcessJobMessage payload.""" + payload = ProcessJobMessage( + job_id=job_id, + storage_url=storage_url, + source_resolution=source_resolution, + target_resolution=target_resolution, + ) + msg = AsyncMock() + msg.data = payload.model_dump_json().encode() + return msg + + +@pytest.mark.asyncio +async def test_already_processed_acks_and_returns( + nats_msg_patches: dict[str, Any], +) -> None: + nats_msg_patches["check"].return_value = True + msg = make_msg() + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + msg.ack.assert_called_once() + nats_msg_patches["upscale"].assert_not_called() + nats_msg_patches["downscale"].assert_not_called() + nats_msg_patches["upload"].assert_not_called() + + +@pytest.mark.asyncio +async def test_already_processed_skips_status_update( + nats_msg_patches: dict[str, Any], +) -> None: + nats_msg_patches["check"].return_value = True + msg = make_msg() + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + nats_msg_patches["update_status"].assert_not_called() + + +@pytest.mark.asyncio +async def test_upscale_path_calls_video_upscale( + nats_msg_patches: dict[str, Any], +) -> None: + model_path = Path("/weights/model.pth") + nats_msg_patches["select"].return_value = (model_path, 2) + msg = make_msg(source_resolution="480p", target_resolution="1080p") + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + nats_msg_patches["upscale"].assert_called_once() + nats_msg_patches["downscale"].assert_not_called() + + +@pytest.mark.asyncio +async def test_downscale_path_calls_video_downscale( + nats_msg_patches: dict[str, Any], +) -> None: + nats_msg_patches["select"].return_value = None + msg = make_msg(source_resolution="1080p", target_resolution="480p") + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + nats_msg_patches["downscale"].assert_called_once() + nats_msg_patches["upscale"].assert_not_called() + + +@pytest.mark.asyncio +async def test_upscale_passes_correct_args(nats_msg_patches: dict[str, Any]) -> None: + model_path = Path("/weights/model.pth") + nats_msg_patches["select"].return_value = (model_path, 4) + nats_msg_patches["fetch"].return_value = "/tmp/video.mp4" + msg = make_msg(job_id="abc", source_resolution="480p", target_resolution="1080p") + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + nats_msg_patches["upscale"].assert_called_once_with( + "/tmp/video.mp4", + "../temp_output/abc/video.mp4", + model_path, + 4, + ANY, + ) + + +@pytest.mark.asyncio +async def test_downscale_passes_correct_args(nats_msg_patches: dict[str, Any]) -> None: + nats_msg_patches["select"].return_value = None + nats_msg_patches["fetch"].return_value = "/tmp/video.mp4" + msg = make_msg(job_id="abc", source_resolution="1080p", target_resolution="480p") + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + nats_msg_patches["downscale"].assert_called_once_with( + "/tmp/video.mp4", + "480p", + "../temp_output/abc/video.mp4", + ) + + +@pytest.mark.asyncio +async def test_invalid_json_naks(nats_msg_patches: dict[str, Any]) -> None: + msg = AsyncMock() + msg.data = b"not valid json" + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + msg.nak.assert_called_once() + msg.ack.assert_not_called() + + +@pytest.mark.asyncio +async def test_fetch_video_raises_naks(nats_msg_patches: dict[str, Any]) -> None: + nats_msg_patches["fetch"].side_effect = RuntimeError("storage down") + msg = make_msg() + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + msg.nak.assert_called_once() + msg.ack.assert_not_called() + + +@pytest.mark.asyncio +async def test_video_upscale_raises_naks(nats_msg_patches: dict[str, Any]) -> None: + nats_msg_patches["select"].return_value = (Path("/weights/model.pth"), 2) + nats_msg_patches["upscale"].side_effect = RuntimeError("gpu oom") + msg = make_msg() + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + msg.nak.assert_called_once() + msg.ack.assert_not_called() + + +@pytest.mark.asyncio +async def test_video_downscale_raises_naks(nats_msg_patches: dict[str, Any]) -> None: + nats_msg_patches["select"].return_value = None + nats_msg_patches["downscale"].side_effect = RuntimeError("ffmpeg failed") + msg = make_msg(source_resolution="1080p", target_resolution="480p") + + await process_msg(AsyncMock(), AsyncMock(), AsyncMock(), msg) + + msg.nak.assert_called_once() + msg.ack.assert_not_called() + + +@pytest.mark.asyncio +async def test_finalize_uploads_to_correct_storage_url( + nats_msg_patches: dict[str, Any], +) -> None: + await _finalize_job( + AsyncMock(), AsyncMock(), AsyncMock(), "job-abc", "/tmp/job-abc/output.mp4" + ) + + expected_url = f"{settings.BASE_STORAGE_URL}/job-abc/output.mp4/processed" + nats_msg_patches["upload"].assert_called_once_with( + expected_url, "job-abc", "/tmp/job-abc/output.mp4", settings.SERVICE_NAME + ) + + +@pytest.mark.asyncio +async def test_finalize_publishes_upscale_complete_msg( + nats_msg_patches: dict[str, Any], +) -> None: + mock_js = AsyncMock() + await _finalize_job(mock_js, AsyncMock(), AsyncMock(), "job-abc", "/tmp/out.mp4") + + nats_msg_patches["pub"].assert_called_once_with( + mock_js, + UpscaleCompleteMsg(job_id="job-abc"), + settings.PUB_SUBJECT, + settings.SERVICE_NAME, + ) + + +@pytest.mark.asyncio +async def test_finalize_marks_job_processed_in_kv( + nats_msg_patches: dict[str, Any], +) -> None: + mock_kv = AsyncMock() + await _finalize_job(AsyncMock(), mock_kv, AsyncMock(), "job-abc", "/tmp/out.mp4") + + mock_kv.put.assert_called_once_with("job-abc", b"done") + + +@pytest.mark.asyncio +async def test_finalize_acks_message(nats_msg_patches: dict[str, Any]) -> None: + msg = AsyncMock() + await _finalize_job(AsyncMock(), AsyncMock(), msg, "job-abc", "/tmp/out.mp4") + + msg.ack.assert_called_once() + + +@pytest.mark.asyncio +async def test_finalize_removes_temp_dirs(nats_msg_patches: dict[str, Any]) -> None: + await _finalize_job( + AsyncMock(), + AsyncMock(), + AsyncMock(), + "job-abc", + "../temp_output/job-abc/video.mp4", + ) + + rmtree_calls = nats_msg_patches["rmtree"].call_args_list + removed_paths = [str(c.args[0]) for c in rmtree_calls] + assert any("job-abc" in p for p in removed_paths) diff --git a/backend/video-upscaling/tests/unit/test_service.py b/backend/video-upscaling/tests/unit/test_service.py new file mode 100644 index 0000000..05eaadd --- /dev/null +++ b/backend/video-upscaling/tests/unit/test_service.py @@ -0,0 +1,126 @@ +from typing import Any +from unittest.mock import patch +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +import nats.js.errors as js_errors +from src.service import start_service +import pytest + + +@pytest.mark.asyncio +async def test_start_service_calls_consumer(service_patches: Any) -> None: + mock_nc, mock_js = service_patches + + with patch("src.service.consumer", new_callable=AsyncMock) as mock_consumer: + await start_service() + + mock_consumer.assert_called_once() + + +@pytest.mark.asyncio +async def test_start_service_drains_nats_on_exit(service_patches: Any) -> None: + mock_nc, mock_js = service_patches + + with patch("src.service.consumer", new_callable=AsyncMock): + await start_service() + + mock_nc.drain.assert_called_once() + + +@pytest.mark.asyncio +async def test_start_service_shuts_down_health_server_on_exit( + service_patches: Any, +) -> None: + mock_nc, _ = service_patches + + with ( + patch("src.service.start_health_server") as mock_health, + patch("src.service.consumer", new_callable=AsyncMock), + ): + mock_server = MagicMock() + mock_health.return_value = mock_server + await start_service() + + mock_server.shutdown.assert_called_once() + + +@pytest.mark.asyncio +async def test_health_server_shutdown_called_even_if_consumer_raises( + service_patches: Any, +) -> None: + with ( + patch("src.service.start_health_server") as mock_health, + patch( + "src.service.consumer", + new_callable=AsyncMock, + side_effect=RuntimeError("boom"), + ), + ): + mock_server = MagicMock() + mock_health.return_value = mock_server + with pytest.raises(RuntimeError): + await start_service() + + mock_server.shutdown.assert_called_once() + + +@pytest.mark.asyncio +async def test_drain_called_even_if_consumer_raises(service_patches: Any) -> None: + mock_nc, _ = service_patches + + with patch( + "src.service.consumer", new_callable=AsyncMock, side_effect=RuntimeError("boom") + ): + with pytest.raises(RuntimeError): + await start_service() + + mock_nc.drain.assert_called_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "setup_js,match", + [ + ( + lambda js: setattr( + js, + "find_stream_name_by_subject", + AsyncMock(side_effect=js_errors.NotFoundError), + ), + None, + ), + ( + lambda js: setattr( + js, "key_value", AsyncMock(side_effect=js_errors.NotFoundError) + ), + "job-status KV bucket not found", + ), + ( + lambda js: setattr( + js, "create_key_value", AsyncMock(side_effect=js_errors.APIError()) + ), + "failed to create upscale-processed KV bucket", + ), + ], + ids=["stream_not_found", "job_status_kv_not_found", "kv_creation_fails"], +) +async def test_raises_on_startup_failure( + service_patches: Any, setup_js: Any, match: str | None +) -> None: + _, mock_js = service_patches + setup_js(mock_js) + + with pytest.raises(RuntimeError, match=match): + await start_service() + + +@pytest.mark.asyncio +async def test_consumer_not_called_when_stream_not_found(service_patches: Any) -> None: + _, mock_js = service_patches + mock_js.find_stream_name_by_subject = AsyncMock(side_effect=js_errors.NotFoundError) + + with patch("src.service.consumer", new_callable=AsyncMock) as mock_consumer: + with pytest.raises(RuntimeError): + await start_service() + + mock_consumer.assert_not_called() diff --git a/backend/video-upscaling/tests/unit/test_video.py b/backend/video-upscaling/tests/unit/test_video.py new file mode 100644 index 0000000..63298ae --- /dev/null +++ b/backend/video-upscaling/tests/unit/test_video.py @@ -0,0 +1,173 @@ +from typing import Any +from pathlib import Path +from unittest.mock import patch +from unittest.mock import MagicMock +from subprocess import CalledProcessError +from src.processing.video import video_decoder +from src.processing.video import video_encoder +from src.processing.video import video_upscale +from src.processing.video import video_downscale +from src.processing.video import extract_video_info +from src.processing.video import recombine_video_audio +from tests.fixtures.processing_helpers import make_fake_decoder +import pytest +import subprocess +import numpy as np + + +@pytest.mark.parametrize("bad_path", ["", None]) +def test_extract_video_info_raises_type_error_for_missing_path( + bad_path: str | None, +) -> None: + with pytest.raises(TypeError, match="Missing video_path input"): + extract_video_info(bad_path) # type: ignore[arg-type] + + +@pytest.mark.parametrize("fps", [0, -1, -30.0]) +def test_video_encoder_raises_for_invalid_fps(fps: float) -> None: + with pytest.raises(ValueError, match="fps cant be negative or 0"): + video_encoder(fps, 1280, 720, "/tmp/out.mp4") + + +@pytest.mark.parametrize("out_w", [0, -1]) +def test_video_encoder_raises_for_invalid_width(out_w: int) -> None: + with pytest.raises(ValueError, match="out_w cant be negative or 0"): + video_encoder(24.0, out_w, 720, "/tmp/out.mp4") + + +@pytest.mark.parametrize("out_h", [0, -1, None]) +def test_video_encoder_raises_for_invalid_height(out_h: int | None) -> None: + with pytest.raises(ValueError, match="out_h cant be negative or 0"): + video_encoder(24.0, 1280, out_h, "/tmp/out.mp4") # type: ignore[arg-type] + + +def test_video_downscale_raises_runtime_error_when_ffmpeg_fails() -> None: + with patch( + "src.processing.video.subprocess.run", + side_effect=CalledProcessError(1, "ffmpeg", stderr=b"error"), + ): + with pytest.raises(RuntimeError, match="ffmpeg downscale failed"): + video_downscale("/tmp/input.mp4", "480p", "/tmp/out.mp4") + + +def test_video_decoder_calls_popen_with_video_path() -> None: + with patch( + "src.processing.video.subprocess.Popen", return_value=MagicMock() + ) as mock_popen: + video_decoder("/tmp/input.mp4") + + args = mock_popen.call_args[0][0] + assert "/tmp/input.mp4" in args + + +def test_video_decoder_returns_popen_instance() -> None: + mock_proc = MagicMock() + with patch("src.processing.video.subprocess.Popen", return_value=mock_proc): + assert video_decoder("/tmp/input.mp4") is mock_proc + + +def test_video_decoder_opens_stdout_pipe() -> None: + with patch( + "src.processing.video.subprocess.Popen", return_value=MagicMock() + ) as mock_popen: + video_decoder("/tmp/input.mp4") + + assert mock_popen.call_args[1]["stdout"] == subprocess.PIPE + + +def test_video_decoder_outputs_rgb24() -> None: + with patch( + "src.processing.video.subprocess.Popen", return_value=MagicMock() + ) as mock_popen: + video_decoder("/tmp/input.mp4") + + args = mock_popen.call_args[0][0] + assert "rgb24" in args + + +def test_recombine_video_audio_calls_subprocess_run() -> None: + with patch("src.processing.video.subprocess.run") as mock_run: + recombine_video_audio("/tmp/original.mp4", "/tmp/final.mp4") + + mock_run.assert_called_once() + + +def test_recombine_video_audio_passes_correct_paths() -> None: + with patch("src.processing.video.subprocess.run") as mock_run: + recombine_video_audio("/tmp/original.mp4", "/tmp/final.mp4") + + args = mock_run.call_args[0][0] + assert "/tmp/upscaled_noaudio.mp4" in args + assert "/tmp/original.mp4" in args + assert "/tmp/final.mp4" in args + + +@pytest.mark.parametrize( + "n_frames,batch_size", + [ + (4, 4), # exactly one full batch + (5, 4), # one full batch + partial remainder + (3, 4), # only a partial batch + ], +) +def test_video_upscale_flushes_all_frames( + video_upscale_patches: dict[str, Any], n_frames: int, batch_size: int +) -> None: + w, h = 64, 64 + frames = [np.zeros((h, w, 3), dtype=np.uint8) for _ in range(n_frames)] + video_upscale_patches["decoder"].return_value = make_fake_decoder(frames) + video_upscale_patches["info"].return_value = (w, h, 24.0, 22) + video_upscale_patches["settings"].BATCH_SIZE = batch_size + + flushed: list[int] = [] + + def capture_flush( + upsampler: object, pending: list[np.ndarray], queue: object + ) -> tuple[float, float, int]: + flushed.append(len(pending)) + return 0.0, 0.0, len(pending) + + video_upscale_patches["flush"].side_effect = capture_flush + + video_upscale("/tmp/input.mp4", "/tmp/output.mp4", Path("/weights/model.pth"), 2) + + assert sum(flushed) == n_frames + + +def test_video_upscale_loads_model_with_correct_args( + video_upscale_patches: dict[str, Any], +) -> None: + video_upscale_patches["decoder"].return_value = make_fake_decoder([]) + + model_path = Path("/weights/model.pth") + video_upscale("/tmp/input.mp4", "/tmp/output.mp4", model_path, 2) + + video_upscale_patches["load"].assert_called_once_with(model_path, 2) + + +def test_video_upscale_encoder_gets_scaled_dimensions( + video_upscale_patches: dict[str, Any], +) -> None: + w, h, scale = 64, 64, 2 + video_upscale_patches["info"].return_value = (w, h, 24.0, 22) + video_upscale_patches["decoder"].return_value = make_fake_decoder([]) + + video_upscale( + "/tmp/input.mp4", "/tmp/output.mp4", Path("/weights/model.pth"), scale + ) + + video_upscale_patches["encoder"].assert_called_once_with( + 24.0, w * scale, h * scale, "/tmp/upscaled_noaudio.mp4" + ) + + +def test_video_upscale_calls_recombine_with_output_path( + video_upscale_patches: dict[str, Any], +) -> None: + video_upscale_patches["decoder"].return_value = make_fake_decoder([]) + + video_upscale("/tmp/input.mp4", "/tmp/output.mp4", Path("/weights/model.pth"), 2) + + video_upscale_patches["recombine"].assert_called_once_with( + "/tmp/input.mp4", "/tmp/output.mp4" + ) diff --git a/backend/video-upscaling/tests/unit/test_worker.py b/backend/video-upscaling/tests/unit/test_worker.py new file mode 100644 index 0000000..8c2c291 --- /dev/null +++ b/backend/video-upscaling/tests/unit/test_worker.py @@ -0,0 +1,74 @@ +from queue import Queue +from typing import Optional +from unittest.mock import MagicMock +from src.processing.worker import encode_worker + + +def _make_encoder(has_stdin: bool = True) -> MagicMock: + encoder = MagicMock() + encoder.stdin = MagicMock() if has_stdin else None + return encoder + + +def _run(frames: list[Optional[bytes]], encoder: MagicMock) -> None: + q: Queue[Optional[bytes]] = Queue() + for f in frames: + q.put(f) + q.put(None) + encode_worker(q, encoder) + + +def test_writes_each_frame_to_encoder_stdin() -> None: + encoder = _make_encoder() + _run([b"frame1", b"frame2", b"frame3"], encoder) + + assert encoder.stdin.write.call_count == 3 + + +def test_writes_frames_in_order() -> None: + encoder = _make_encoder() + _run([b"first", b"second"], encoder) + + calls = [c.args[0] for c in encoder.stdin.write.call_args_list] + assert calls == [b"first", b"second"] + + +def test_does_not_write_none_sentinel_to_stdin() -> None: + encoder = _make_encoder() + _run([b"frame"], encoder) + + written = [c.args[0] for c in encoder.stdin.write.call_args_list] + assert None not in written + + +def test_no_writes_when_queue_only_has_sentinel() -> None: + encoder = _make_encoder() + _run([], encoder) + + encoder.stdin.write.assert_not_called() + + +def test_closes_stdin_after_sentinel() -> None: + encoder = _make_encoder() + _run([b"frame"], encoder) + + encoder.stdin.close.assert_called_once() + + +def test_calls_wait_after_closing_stdin() -> None: + encoder = _make_encoder() + _run([b"frame"], encoder) + + encoder.wait.assert_called_once() + + +def test_does_not_write_when_stdin_is_none() -> None: + encoder = _make_encoder(has_stdin=False) + _run([b"frame1", b"frame2"], encoder) # should not raise + + +def test_does_not_close_stdin_when_stdin_is_none() -> None: + encoder = _make_encoder(has_stdin=False) + _run([b"frame"], encoder) + + assert encoder.stdin is None # no close attempted, no AttributeError diff --git a/backend/video-upscaling/uv.lock b/backend/video-upscaling/uv.lock new file mode 100644 index 0000000..27ab875 --- /dev/null +++ b/backend/video-upscaling/uv.lock @@ -0,0 +1,1996 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "sys_platform == 'linux' or sys_platform == 'win32'", + "sys_platform != 'linux' and sys_platform != 'win32'", +] + +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + +[[package]] +name = "addict" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ef/fd7649da8af11d93979831e8f1f8097e85e82d5bfeabc8c68b39175d8e75/addict-2.4.0.tar.gz", hash = "sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494", size = 9186, upload-time = "2020-11-21T16:21:31.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "basicsr" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "addict" }, + { name = "future" }, + { name = "lmdb" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "scikit-image" }, + { name = "scipy" }, + { name = "tb-nightly" }, + { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "torchvision", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm" }, + { name = "yapf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/41/00a6b000f222f0fa4c6d9e1d6dcc9811a374cabb8abb9d408b77de39648c/basicsr-1.4.2.tar.gz", hash = "sha256:b89b595a87ef964cda9913b4d99380ddb6554c965577c0c10cb7b78e31301e87", size = 172524, upload-time = "2022-08-30T04:33:55.259Z" } + +[[package]] +name = "basicsr-fixed" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "addict" }, + { name = "future" }, + { name = "lmdb" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "scikit-image" }, + { name = "scipy" }, + { name = "tb-nightly" }, + { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "torchvision", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm" }, + { name = "yapf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/06/f41e0d7b6404be190545cc81afb27f84497f8feb33ff6f3175e693817709/basicsr-fixed-1.4.2.tar.gz", hash = "sha256:a9dd0989bc4af9e0dda656c83e61f9b01ff89da1f432018e575e56ad25e0d684", size = 173663, upload-time = "2025-01-20T06:00:05.849Z" } + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[[package]] +name = "cuda-bindings" +version = "12.9.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/ad/2d9b80c28deae971ce4bbe991c23b81347a2a8918b2672020d07f070a596/cuda_bindings-12.9.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da30d89db8188b9beb5a6467d72b2f11d1b667ab901d2d373bcde51b97765b21", size = 6950608, upload-time = "2026-03-11T14:47:40.944Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ca/729781d11445cfbacd1af1bf0edfe147c311212cfdf1d5c292e0565fabef/cuda_bindings-12.9.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d1be8bd80b34f51dcbaf138dafd817e888cf2d12c47833019fd933beb32d7ef", size = 7439531, upload-time = "2026-03-11T14:47:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f3/51768221aade33e711dcf7e4a52fdc0d0446c1baf39f6bcc9d69cfbceb0b/cuda_bindings-12.9.6-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48666e666f083a4c4387ffe20594b05e092b535a4453d1e4817d71237d02aa13", size = 6861186, upload-time = "2026-03-11T14:47:46.335Z" }, + { url = "https://files.pythonhosted.org/packages/71/34/14afff4aabe3b5bd84c647dea4a4dfb917c94b8a8df0adb6b1622c2b465b/cuda_bindings-12.9.6-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4f82f8f8061f3a39446bf854c4edd9bcc2d0da3f58d8f6f54541b3e4d5c933d", size = 7356548, upload-time = "2026-03-11T14:47:48.209Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d3/a29faf4fb371c2f43ffda23a938ec0bebf6dbab676350e137ae0f61e5ec0/cuda_bindings-12.9.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f00290f9468d2cfeee92aaad2275be32dfd2f4967a97ac0f12314b7e6281ad78", size = 7046617, upload-time = "2026-03-11T14:47:52.46Z" }, + { url = "https://files.pythonhosted.org/packages/2a/97/71e66b2ed65d80f7b70a1538af72d73cd798e22bc93d240d7e69f2366322/cuda_bindings-12.9.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3bc6e28cf5d133f72050c515db72876870fb009f1431bcbf45b54a179be2284", size = 7481379, upload-time = "2026-03-11T14:47:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/49/91/c10b575a001aad39c036efd649869aac8d97ef0ba9f1d8ad17b4946b3366/cuda_bindings-12.9.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e88d38fdf07cc777dec1afaba8139c2eedb3819063f6b42f1e2ea8516bdd6806", size = 6879714, upload-time = "2026-03-11T14:47:58.095Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9a/998471e76bea78e96d3d7fdf0bc5f46c3210858e81e6d13d8186a9dbb636/cuda_bindings-12.9.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df01e34cefd3275170b2ac0426d325271ab435e85f59a69300eacd8ff23d34c", size = 7367020, upload-time = "2026-03-11T14:47:59.781Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/d6/ac63065d33dd700fee7ebd7d287332401b54e31b9346e142f871e1f0b116/cuda_pathfinder-1.5.3-py3-none-any.whl", hash = "sha256:dff021123aedbb4117cc7ec81717bbfe198fb4e8b5f1ee57e0e084fec5c8577d", size = 49991, upload-time = "2026-04-14T20:09:27.037Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "12.8.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/c8/7dce3a0b15b42a3b58e7d96eb22a687d3bf2c44e01d149a6874629cd9938/cuda_toolkit-12.8.1-py2.py3-none-any.whl", hash = "sha256:adc7906af4ecbf9a352f9dca5734eceb21daec281ccfcf5675e1d2f724fc2cba", size = 2283, upload-time = "2025-08-13T02:03:07.842Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cufft = [ + { name = "nvidia-cufft-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cufile = [ + { name = "nvidia-cufile-cu12", marker = "sys_platform == 'linux'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +curand = [ + { name = "nvidia-curand-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cusolver = [ + { name = "nvidia-cusolver-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +cusparse = [ + { name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +nvtx = [ + { name = "nvidia-nvtx-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "facexlib" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filterpy" }, + { name = "numba" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "torchvision", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/93/c820cd2c6315b635934770808e0b01ed4db257ec33bcf803909dcf4bce15/facexlib-0.3.0.tar.gz", hash = "sha256:7ae784a520eb52e05583e8bf9f68f77f45083239ac754d646d635017b49e7763", size = 1066362, upload-time = "2023-04-15T06:51:59.169Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/7b/2147339dafe1c4800514c9c21ee4444f8b419ce51dfc7695220a8e0069a6/facexlib-0.3.0-py3-none-any.whl", hash = "sha256:245d58861537b820c616e8b3ef618ccfad2a24724a2d74be2b0542643c01a878", size = 59624, upload-time = "2023-04-15T06:51:56.841Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "filterpy" +version = "1.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/1d/ac8914360460fafa1990890259b7fa5ef7ba4cd59014e782e4ab3ab144d8/filterpy-1.4.5.zip", hash = "sha256:4f2a4d39e4ea601b9ab42b2db08b5918a9538c168cff1c6895ae26646f3d73b1", size = 177985, upload-time = "2018-10-10T22:38:24.63Z" } + +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, + { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, + { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, + { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, + { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, + { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, + { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, + { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, + { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, +] + +[[package]] +name = "future" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, +] + +[[package]] +name = "gfpgan" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "basicsr" }, + { name = "facexlib" }, + { name = "lmdb" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pyyaml" }, + { name = "scipy" }, + { name = "tb-nightly" }, + { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "torchvision", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm" }, + { name = "yapf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/e9/b2db24ed840f188792581d217229022ff85e0ae3055a708e9f28430b8083/gfpgan-1.3.8.tar.gz", hash = "sha256:21618b06ce8ea6230448cb526b012004f23a9ab956b55c833f69b9fc8a60c4f9", size = 95855, upload-time = "2022-09-16T11:36:05.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/a2/84bb50a2655fda1e6f35ae57399526051b8a8b96ad730aea82abeaac4de8/gfpgan-1.3.8-py3-none-any.whl", hash = "sha256:3d8386df6320aa9dfb0dd4cd09d9f8ed12ae0bbd9b2df257c3d21aefac5d8b85", size = 52176, upload-time = "2022-09-16T11:36:04.243Z" }, +] + +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +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" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, +] + +[[package]] +name = "lazy-loader" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/ac/21a1f8aa3777f5658576777ea76bfb124b702c520bbe90edf4ae9915eafa/lazy_loader-0.5.tar.gz", hash = "sha256:717f9179a0dbed357012ddad50a5ad3d5e4d9a0b8712680d4e687f5e6e6ed9b3", size = 15294, upload-time = "2026-03-06T15:45:09.054Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/a1/8d812e53a5da1687abb10445275d41a8b13adb781bbf7196ddbcf8d88505/lazy_loader-0.5-py3-none-any.whl", hash = "sha256:ab0ea149e9c554d4ffeeb21105ac60bed7f3b4fd69b1d2360a4add51b170b005", size = 8044, upload-time = "2026-03-06T15:45:07.668Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/6f/4615353e016799f80fa52ccb270a843c413b22361fadda2589b2922fb9b0/llvmlite-0.47.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a3c6a735d4e1041808434f9d440faa3d78d9b4af2ee64d05a66f351883b6ceec", size = 37232771, upload-time = "2026-03-31T18:29:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/b8/69f5565f1a280d032525878a86511eebed0645818492feeb169dfb20ae8e/llvmlite-0.47.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2699a74321189e812d476a43d6d7f652f51811e7b5aad9d9bba842a1c7927acb", size = 56275178, upload-time = "2026-03-31T18:29:05.748Z" }, + { url = "https://files.pythonhosted.org/packages/d6/da/b32cafcb926fb0ce2aa25553bf32cb8764af31438f40e2481df08884c947/llvmlite-0.47.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c6951e2b29930227963e53ee152441f0e14be92e9d4231852102d986c761e40", size = 55128632, upload-time = "2026-03-31T18:29:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/4898b44e4042c60fafcb1162dfb7014f6f15b1ec19bf29cfea6bf26df90d/llvmlite-0.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2e9adf8698d813a9a5efb2d4370caf344dbc1e145019851fee6a6f319ba760e", size = 38138695, upload-time = "2026-03-31T18:29:15.43Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d4/33c8af00f0bf6f552d74f3a054f648af2c5bc6bece97972f3bfadce4f5ec/llvmlite-0.47.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:de966c626c35c9dff5ae7bf12db25637738d0df83fc370cf793bc94d43d92d14", size = 37232773, upload-time = "2026-03-31T18:29:19.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/1d/a760e993e0c0ba6db38d46b9f48f6c7dceb8ac838824997fb9e25f97bc04/llvmlite-0.47.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ddbccff2aeaff8670368340a158abefc032fe9b3ccf7d9c496639263d00151aa", size = 56275176, upload-time = "2026-03-31T18:29:24.149Z" }, + { url = "https://files.pythonhosted.org/packages/84/3b/e679bc3b29127182a7f4aa2d2e9e5bea42adb93fb840484147d59c236299/llvmlite-0.47.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4a7b778a2e144fc64468fb9bf509ac1226c9813a00b4d7afea5d988c4e22fca", size = 55128631, upload-time = "2026-03-31T18:29:29.536Z" }, + { url = "https://files.pythonhosted.org/packages/be/f7/19e2a09c62809c9e63bbd14ce71fb92c6ff7b7b3045741bb00c781efc3c9/llvmlite-0.47.0-cp314-cp314-win_amd64.whl", hash = "sha256:694e3c2cdc472ed2bd8bd4555ca002eec4310961dd58ef791d508f57b5cc4c94", size = 39153826, upload-time = "2026-03-31T18:29:33.681Z" }, + { url = "https://files.pythonhosted.org/packages/40/a1/581a8c707b5e80efdbbe1dd94527404d33fe50bceb71f39d5a7e11bd57b7/llvmlite-0.47.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:92ec8a169a20b473c1c54d4695e371bde36489fc1efa3688e11e99beba0abf9c", size = 37232772, upload-time = "2026-03-31T18:29:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/11/03/16090dd6f74ba2b8b922276047f15962fbeea0a75d5601607edb301ba945/llvmlite-0.47.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbd800edd3b20bc141521f7fd45a6185a5b84109aa6855134e81397ffe72b", size = 56275178, upload-time = "2026-03-31T18:29:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cb/0abf1dd4c5286a95ffe0c1d8c67aec06b515894a0dd2ac97f5e27b82ab0b/llvmlite-0.47.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6725179b89f03b17dabe236ff3422cb8291b4c1bf40af152826dfd34e350ae8", size = 55128632, upload-time = "2026-03-31T18:29:46.939Z" }, + { url = "https://files.pythonhosted.org/packages/4f/79/d3bbab197e86e0ff4f9c07122895b66a3e0d024247fcff7f12c473cb36d9/llvmlite-0.47.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6842cf6f707ec4be3d985a385ad03f72b2d724439e118fcbe99b2929964f0453", size = 39153839, upload-time = "2026-03-31T18:29:51.004Z" }, +] + +[[package]] +name = "lmdb" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/44/d94934efaf8f887b6959f131fde740fcaa831edfd13eb5425574637cddd5/lmdb-2.2.0.tar.gz", hash = "sha256:53020e20305c043ea6e68089bc242d744fba6073cdb268332299ba6dda2886d4", size = 933189, upload-time = "2026-03-30T01:26:19.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/43/543af71e8fa4c56623bb89c358121ab806426f26685f11539fe5452deffa/lmdb-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36e0cbe6b7d59f6e19b448942c5f9e91674f596a802743258f82e926a9a09632", size = 113550, upload-time = "2026-03-30T01:25:55.727Z" }, + { url = "https://files.pythonhosted.org/packages/22/2c/4702d36c0073737554b20d1d62e879a066df963482f8e514866588ddd82d/lmdb-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5d7a9dfd279a5884806fd478244961e4483cc6d7eb769caed1d7019a8608c20", size = 112135, upload-time = "2026-03-30T01:25:56.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/43/d015fea326ed0a634107f29740b002170a462b6d2481e509105c685520f5/lmdb-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dbe7902b2cdb60bf6c893f307ef2b2a5039afd22f029515b86183f05ab1353", size = 332108, upload-time = "2026-03-30T01:25:57.907Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c9/503e7f173994b514936badcbcb7fa9f89a07a3cfe596c6fb95b1b91b8d70/lmdb-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c576cdb163ae61a7ef6eecbc20a6025a4abe085491c1dc0c667d726f4926b53", size = 336017, upload-time = "2026-03-30T01:25:59.234Z" }, + { url = "https://files.pythonhosted.org/packages/3e/94/b3b064acfd2f8acf5aaa53fff2c43963dbc1932ba8b8df4e27d75bf6a34a/lmdb-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:746eebcd4c0aeaf0eb2f897028929d270c5bc80ef4918500eec16db6f26f3fcc", size = 109574, upload-time = "2026-03-30T01:26:00.324Z" }, + { url = "https://files.pythonhosted.org/packages/b9/10/dc7488d1effc339cd9470f9d22ec0fd7052a3d4fdfae87765ecd41cb2e59/lmdb-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:006153aac9fb0415a5f3e8ac88789e5730dba3dd0743cd84c95e3951ff68bc3a", size = 103810, upload-time = "2026-03-30T01:26:01.559Z" }, + { url = "https://files.pythonhosted.org/packages/36/3f/452a81add862d99722e18c92b2a0202d9bb316fb19422150b4424ec7a983/lmdb-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0398fef4ab54d66f531257e7a68c03314a267da5d2fd76d75481f62a237ec28b", size = 113740, upload-time = "2026-03-30T01:26:02.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/62edf6b273d4118c0ed4b5afc5797ca68091e360daa91ef77ae8337084db/lmdb-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1610c601f397b0b523310b2393fd430f5973bfdb5dbec9cdc5f89510c5e887ca", size = 112192, upload-time = "2026-03-30T01:26:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/dd/60/19c59e022e84dad27932b7af58319dd20afdb7de4f48698cac408f6066ab/lmdb-2.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc1d32c08afdbc7db1315d199c827e14c6ad8cdfc7d70872ff983f68079a5edb", size = 331709, upload-time = "2026-03-30T01:26:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/a9/51/28a24bd3d131ecc7b74c1dac06eea9194e05efe2af5032dece703d397a67/lmdb-2.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdba273466d099f3ff3a10a26dc1d45101ac519bf67ae23b402a2f3191965e13", size = 334891, upload-time = "2026-03-30T01:26:06.328Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e6/3071576af6c318f76f36ac3b52f2f809b861d13193c6bbc004bdabd451de/lmdb-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:00a051a0d29e0d88e84035884e91a57e2e850355c7e1a3ea05c34753a56d3e12", size = 111309, upload-time = "2026-03-30T01:26:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5d/723eabbfe716013db0d13c2015784249e91c87524cde1539c6b99daac68e/lmdb-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:2d2968d2a3ff6e69596d9604d2029d2d1265079aa2864eb721c27e076a1fd792", size = 106210, upload-time = "2026-03-30T01:26:09.231Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { 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" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, + { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "nats-py" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/f8/b956c4621ba88748ed707c52e69f95b7a50c8914e750edca59a5bef84a76/nats_py-2.14.0.tar.gz", hash = "sha256:4ed02cb8e3b55c68074a063aa2687087115d805d1513297da90cb2068fb07bed", size = 120751, upload-time = "2026-02-23T22:44:58.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/39/0e87753df1072254bac190b33ed34b264f28f6aa9bea0f01b7e818071756/nats_py-2.14.0-py3-none-any.whl", hash = "sha256:4116f5d2233ce16e63c3d5538fa40a5e207f75fcf42a741773929ddf1e29d19d", size = 82259, upload-time = "2026-02-23T22:45:00.152Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numba" +version = "0.65.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/61/7299643b9c18d669e04be7c5bcb64d985070d07553274817b45b049e7bfe/numba-0.65.0.tar.gz", hash = "sha256:edad0d9f6682e93624c00125a471ae4df186175d71fd604c983c377cdc03e68b", size = 2764131, upload-time = "2026-04-01T03:52:01.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/f8/eee0f1ff456218db036bfc9023995ec1f85a9dc8f2422f1594f6a87829e0/numba-0.65.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c6334094563a456a695c812e6846288376ca02327cf246cdcc83e1bb27862367", size = 2680679, upload-time = "2026-04-01T03:51:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8f/3d116e4b8e92f6abace431afa4b2b944f4d65bdee83af886f5c4b263df95/numba-0.65.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b8a9008411615c69d083d1dcf477f75a5aa727b30beb16e139799e2be945cdfd", size = 3809537, upload-time = "2026-04-01T03:51:41.42Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/6a3ca4128e253cb67affe06deb47688f51ce968f5111e2a06d010e6f1fa6/numba-0.65.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af96c0cba53664efcb361528b8c75e011a6556c859c7e08424c2715201c6cf7a", size = 3508615, upload-time = "2026-04-01T03:51:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/96/0e/267f9a36fb282c104a971d7eecb685b411c47dce2a740fe69cf5fc2945d9/numba-0.65.0-cp313-cp313-win_amd64.whl", hash = "sha256:6254e73b9c929dc736a1fbd3d6f5680789709a5067cae1fa7198707385129c04", size = 2749938, upload-time = "2026-04-01T03:51:45.218Z" }, + { url = "https://files.pythonhosted.org/packages/56/a4/90edb01e9176053578e343d7a7276bc28356741ee67059aed8ed2c1a4e59/numba-0.65.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:ee336b398a6fca51b1f626034de99f50cb1bd87d537a166275158a3cee744b82", size = 2680878, upload-time = "2026-04-01T03:51:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/24/8d/e12d6ff4b9119db3cbf7b2db1ce257576441bd3c76388c786dea74f20b02/numba-0.65.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05c0a9fdf75d85f57dee47b719e8d6415707b80aae45d75f63f9dc1b935c29f7", size = 3778456, upload-time = "2026-04-01T03:51:48.552Z" }, + { url = "https://files.pythonhosted.org/packages/17/89/abcd83e76f6a773276fe76244140671bcc5bf820f6e2ae1a15362ae4c8c9/numba-0.65.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:583680e0e8faf124d362df23b4b593f3221a8996341a63d1b664c122401bec2f", size = 3478464, upload-time = "2026-04-01T03:51:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/73/5b/fbce55ce3d933afbc7ade04df826853e4a846aaa47d58d2fbb669b8f2d08/numba-0.65.0-cp314-cp314-win_amd64.whl", hash = "sha256:add297d3e1c08dd884f44100152612fa41e66a51d15fdf91307f9dde31d06830", size = 2752012, upload-time = "2026-04-01T03:51:52.691Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ab/af705f4257d9388fb2fd6d7416573e98b6ca9c786e8b58f02720978557bd/numba-0.65.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:194a243ba53a9157c8538cbb3166ec015d785a8c5d584d06cdd88bee902233c7", size = 2683961, upload-time = "2026-04-01T03:51:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e5/8267b0adb0c01b52b553df5062fbbb42c30ed5362d08b85cc913a36f838f/numba-0.65.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7fa502960f7a2f3f5cb025bc7bff888a3551277b92431bfdc5ba2f11a375749", size = 3816373, upload-time = "2026-04-01T03:51:56.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f5/b8397ca360971669a93706b9274592b6864e4367a37d498fbbcb62aa2d48/numba-0.65.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5046c63f783ca3eb6195f826a50797465e7c4ce811daa17c9bea47e310c9b964", size = 3532782, upload-time = "2026-04-01T03:51:58.387Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1e73fa16bf0393ebb74c5bb208d712152ffdfc84600a8e93a3180317856e/numba-0.65.0-cp314-cp314t-win_amd64.whl", hash = "sha256:46fd679ae4f68c7a5d5721efbd29ecee0b0f3013211591891d79b51bfdf73113", size = 2757611, upload-time = "2026-04-01T03:52:00.083Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.8.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/99/db44d685f0e257ff0e213ade1964fc459b4a690a73293220e98feb3307cf/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b86f6dd8935884615a0683b663891d43781b819ac4f2ba2b0c9604676af346d0", size = 590537124, upload-time = "2025-03-07T01:43:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/b3bd73445e5cb342727fd24fe1f7b748f690b460acadc27ea22f904502c8/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4412396548808ddfed3f17a467b104ba7751e6b58678a4b840675c56d21cf7ed", size = 9533318, upload-time = "2025-03-07T01:40:10.421Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d1/e50d0acaab360482034b84b6e27ee83c6738f7d32182b987f9c7a4e32962/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc1fec1e1637854b4c0a65fb9a8346b51dd9ee69e61ebaccc82058441f15bce8", size = 43106076, upload-time = "2025-03-07T01:41:59.817Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/75/f865a3b236e4647605ea34cc450900854ba123834a5f1598e160b9530c3a/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:52bf7bbee900262ffefe5e9d5a2a69a30d97e2bc5bb6cc866688caa976966e3d", size = 965265, upload-time = "2025-03-07T01:39:43.533Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.19.0.56" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/b8/277c51962ee46fa3e5b203ac5f76107c650f781d6891e681e28e6f3e9fe6/nvidia_cudnn_cu12-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:08caaf27fe556aca82a3ee3b5aa49a77e7de0cfcb7ff4e5c29da426387a8267e", size = 656910700, upload-time = "2026-02-03T20:40:25.508Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/65225d42fba06fb3dd3972485ea258e7dd07a40d6e01c95da6766ad87354/nvidia_cudnn_cu12-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ac6ad90a075bb33a94f2b4cf4622eac13dd4dc65cf6dd9c7572a318516a36625", size = 657906812, upload-time = "2026-02-03T20:44:12.638Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.3.83" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/bc/7771846d3a0272026c416fbb7e5f4c1f146d6d80704534d0b187dd6f4800/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:848ef7224d6305cdb2a4df928759dca7b1201874787083b6e7550dd6765ce69a", size = 193109211, upload-time = "2025-03-07T01:44:56.873Z" }, + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.13.1.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f5/5607710447a6fe9fd9b3283956fceeee8a06cda1d2f56ce31371f595db2a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:4beb6d4cce47c1a0f1013d72e02b0994730359e17801d395bdcbf20cfb3bb00a", size = 1120705, upload-time = "2025-03-07T01:45:41.434Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.9.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/5e/92aa15eca622a388b80fbf8375d4760738df6285b1e92c43d37390a33a9a/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:dfab99248034673b779bc6decafdc3404a8a6f502462201f2f31f11354204acd", size = 63625754, upload-time = "2025-03-07T01:46:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.3.90" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/32/f7cd6ce8a7690544d084ea21c26e910a97e077c9b7f07bf5de623ee19981/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:db9ed69dbef9715071232caa9b69c52ac7de3a95773c2db65bdba85916e4e5c0", size = 267229841, upload-time = "2025-03-07T01:46:54.356Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.8.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f7/cd777c4109681367721b00a106f491e0d0d15cfa1fd59672ce580ce42a97/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b6c161cb130be1a07a27ea6923df8141f3c295852f4b260c65f18f3e0a091dc", size = 288117129, upload-time = "2025-03-07T01:47:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b9/598f6ff36faaece4b3c50d26f50e38661499ff34346f00e057760b35cc9d/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8878dce784d0fac90131b6817b607e803c36e629ba34dc5b433471382196b6a5", size = 283835557, upload-time = "2025-02-26T00:16:54.265Z" }, + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.28.9" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/c4/120d2dfd92dff2c776d68f361ff8705fdea2ca64e20b612fab0fd3f581ac/nvidia_nccl_cu12-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:50a36e01c4a090b9f9c47d92cec54964de6b9fcb3362d0e19b8ffc6323c21b60", size = 296766525, upload-time = "2025-11-18T05:49:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/44dbb46b3d1b0ec61afda8e84837870f2f9ace33c564317d59b70bc19d3e/nvidia_nccl_cu12-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:485776daa8447da5da39681af455aa3b2c2586ddcf4af8772495e7c532c7e5ab", size = 296782137, upload-time = "2025-11-18T05:49:34.248Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a2/8cee5da30d13430e87bf99bb33455d2724d0a4a9cb5d7926d80ccb96d008/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:adccd7161ace7261e01bb91e44e88da350895c270d23f744f0820c818b7229e7", size = 38386204, upload-time = "2025-03-07T01:49:43.612Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu12" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6a/03aa43cc9bd3ad91553a88b5f6fb25ed6a3752ae86ce2180221962bc2aa5/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b48363fc6964dede448029434c6abed6c5e37f823cb43c3bcde7ecfc0457e15", size = 138936938, upload-time = "2025-09-06T00:32:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/c0/1b303feea90d296f6176f32a2a70b5ef230f9bdeb3a72bddb0dc922dc137/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7ad891da111ebafbf7e015d34879f7112832fc239ff0d7d776b6cb685274615", size = 91161, upload-time = "2025-03-07T01:42:23.922Z" }, + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/42/2310883be3b8826ac58c3f2787b9358a2d46923d61f88fedf930bc59c60c/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1a7d040ac656c11b8c38677cc8cccdc149f98535089dbe5b081e80a4e5903209", size = 46247192, upload-time = "2026-02-05T07:01:35.187Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1e/6f9e38005a6f7f22af785df42a43139d0e20f169eb5787ce8be37ee7fcc9/opencv_python_headless-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:3e0a6f0a37994ec6ce5f59e936be21d5d6384a4556f2d2da9c2f9c5dc948394c", size = 32568914, upload-time = "2026-02-05T07:01:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709, upload-time = "2026-02-05T10:24:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764, upload-time = "2026-02-05T10:26:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236, upload-time = "2026-02-05T10:28:11.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106, upload-time = "2026-02-05T10:30:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/52cfea47cd33e53e8c0fbd6e7c800b457245c1fda7d61660b4ffe9596a7f/opencv_python_headless-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:a7cf08e5b191f4ebb530791acc0825a7986e0d0dee2a3c491184bd8599848a4b", size = 30812232, upload-time = "2026-02-05T07:02:29.594Z" }, + { url = "https://files.pythonhosted.org/packages/4a/90/b338326131ccb2aaa3c2c85d00f41822c0050139a4bfe723cfd95455bd2d/opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:77a82fe35ddcec0f62c15f2ba8a12ecc2ed4207c17b0902c7a3151ae29f37fb6", size = 40070414, upload-time = "2026-02-05T07:02:26.448Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836, upload-time = "2026-04-17T09:31:59.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e", size = 471947, upload-time = "2026-04-17T09:31:57.541Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269, upload-time = "2026-04-17T09:10:07.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/2b/662e48254479a2d3450ba24b1e25061108b64339794232f503990c519144/pydantic_core-2.46.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d26e9eea3715008a09a74585fe9becd0c67fbb145dc4df9756d597d7230a652c", size = 2101762, upload-time = "2026-04-17T09:10:13.87Z" }, + { url = "https://files.pythonhosted.org/packages/73/ab/bafd7c7503757ccc8ec4d1911e106fe474c629443648c51a88f08b0fe91a/pydantic_core-2.46.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48b36e3235140510dc7861f0cd58b714b1cdd3d48f75e10ce52e69866b746f10", size = 1951814, upload-time = "2026-04-17T09:12:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/7549c2d57ba2e9a42caa5861a2d398dbe31c02c6aca783253ace59ce84f8/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b1f99dc451f1a3981f236151465bcf995bbe712d0727c9f7b236fe228a8133", size = 1977329, upload-time = "2026-04-17T09:13:37.605Z" }, + { url = "https://files.pythonhosted.org/packages/18/50/7ed4a8a0d478a4dca8f0134a5efa7193f03cc8520dd4c9509339fb2e5002/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8641c8d535c2d95b45c2e19b646ecd23ebba35d461e0ae48a3498277006250ab", size = 2051832, upload-time = "2026-04-17T09:12:49.771Z" }, + { url = "https://files.pythonhosted.org/packages/dc/16/bb35b193741c0298ddc5f5e4234269efdc0c65e2bcd198aa0de9b68845e4/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20fb194788a0a50993e87013e693494ba183a2af5b44e99cf060bbae10912b11", size = 2233127, upload-time = "2026-04-17T09:11:04.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/a5/98f4b637149185addea19e1785ea20c373cca31b202f589111d8209d9873/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9262d11d0cd11ee3303a95156939402bed6cedfe5ed0e331b95a283a4da6eb8b", size = 2297418, upload-time = "2026-04-17T09:11:25.929Z" }, + { url = "https://files.pythonhosted.org/packages/36/90/93a5d21990b152da7b7507b7fddb0b935f6a0984d57ac3ec45a6e17777a2/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac204542736aa295fa25f713b7fad6fc50b46ab7764d16087575c85f085174f3", size = 2093735, upload-time = "2026-04-17T09:12:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/14/22/b8b1ffdddf08b4e84380bcb67f41dbbf4c171377c1d36fc6290794bb2094/pydantic_core-2.46.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9a7c43a0584742dface3ca0daf6f719d46c1ac2f87cf080050f9ae052c75e1b2", size = 2127570, upload-time = "2026-04-17T09:11:53.906Z" }, + { url = "https://files.pythonhosted.org/packages/c6/26/e60d72b4e2d0ce1fa811044a974412ac1c567fe067d97b3e6b290530786e/pydantic_core-2.46.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd05e1edb6a90ad446fa268ab09e59202766b837597b714b2492db11ee87fab9", size = 2183524, upload-time = "2026-04-17T09:11:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/35/32/36bec7584a1eefb17dec4dfa1c946d3fe4440f466c5705b8adfda69c9a9f/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:91155b110788b5501abc7ea954f1d08606219e4e28e3c73a94124307c06efb80", size = 2185408, upload-time = "2026-04-17T09:10:57.228Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d6/1a5689d873620efd67d6b163db0c444c056adb0849b5bc33e2b9f09665a6/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e4e2c72a529fa03ff228be1d2b76944013f428220b764e03cc50ada67e17a42c", size = 2335171, upload-time = "2026-04-17T09:11:43.369Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/675104802abe8ef502b072050ee5f2e915251aa1a3af87e1015ce31ec42d/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:56291ec1a11c3499890c99a8fd9053b47e60fe837a77ec72c0671b1b8b3dce24", size = 2362743, upload-time = "2026-04-17T09:10:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/8d/bc/86c5dde4fa6e24467680eef5047da3c1a19be0a527d0d8e14aa76b39307c/pydantic_core-2.46.2-cp313-cp313-win32.whl", hash = "sha256:b50f9c5f826ddca1246f055148df939f5f3f2d0d96db73de28e2233f22210d4c", size = 1958074, upload-time = "2026-04-17T09:12:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/2a/97/2537e8c1282b2c4eb062580c0d7a4339e10b072b803d1ee0b7f1f0a5c22c/pydantic_core-2.46.2-cp313-cp313-win_amd64.whl", hash = "sha256:251a57788823230ca8cbc99e6245d1a2ed6e180ec4864f251c94182c580c7f2e", size = 2071741, upload-time = "2026-04-17T09:13:32.405Z" }, + { url = "https://files.pythonhosted.org/packages/da/aa/2ee75798706f9dbc4e76dbe59e41a396c5c311e3d6223b9cf6a5fa7780be/pydantic_core-2.46.2-cp313-cp313-win_arm64.whl", hash = "sha256:315d32d1a71494d6b4e1e14a9fa7a4329597b4c4340088ad7e1a9dafbeed92a9", size = 2025955, upload-time = "2026-04-17T09:10:15.567Z" }, + { url = "https://files.pythonhosted.org/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d", size = 2097111, upload-time = "2026-04-17T09:10:49.617Z" }, + { url = "https://files.pythonhosted.org/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6", size = 1951904, upload-time = "2026-04-17T09:12:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2", size = 1978667, upload-time = "2026-04-17T09:11:35.589Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e", size = 2046721, upload-time = "2026-04-17T09:11:47.725Z" }, + { url = "https://files.pythonhosted.org/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973", size = 2228483, upload-time = "2026-04-17T09:12:08.837Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992", size = 2294663, upload-time = "2026-04-17T09:12:52.053Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059", size = 2098742, upload-time = "2026-04-17T09:13:45.472Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78", size = 2125922, upload-time = "2026-04-17T09:12:54.304Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83", size = 2183000, upload-time = "2026-04-17T09:10:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677", size = 2180335, upload-time = "2026-04-17T09:12:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108", size = 2330002, upload-time = "2026-04-17T09:12:02.958Z" }, + { url = "https://files.pythonhosted.org/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8", size = 2359920, upload-time = "2026-04-17T09:10:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e", size = 1953701, upload-time = "2026-04-17T09:13:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867, upload-time = "2026-04-17T09:10:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252, upload-time = "2026-04-17T09:10:26.175Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb", size = 2094545, upload-time = "2026-04-17T09:12:19.339Z" }, + { url = "https://files.pythonhosted.org/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f", size = 1933953, upload-time = "2026-04-17T09:09:55.889Z" }, + { url = "https://files.pythonhosted.org/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72", size = 1974435, upload-time = "2026-04-17T09:10:12.371Z" }, + { url = "https://files.pythonhosted.org/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208", size = 2031113, upload-time = "2026-04-17T09:10:00.752Z" }, + { url = "https://files.pythonhosted.org/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120", size = 2236325, upload-time = "2026-04-17T09:10:33.227Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd", size = 2278135, upload-time = "2026-04-17T09:10:23.287Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830", size = 2109071, upload-time = "2026-04-17T09:11:06.149Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0", size = 2106028, upload-time = "2026-04-17T09:10:51.525Z" }, + { url = "https://files.pythonhosted.org/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d", size = 2164816, upload-time = "2026-04-17T09:11:56.187Z" }, + { url = "https://files.pythonhosted.org/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a", size = 2166130, upload-time = "2026-04-17T09:10:03.804Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70", size = 2316605, upload-time = "2026-04-17T09:12:45.249Z" }, + { url = "https://files.pythonhosted.org/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972", size = 2358385, upload-time = "2026-04-17T09:09:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664", size = 1942373, upload-time = "2026-04-17T09:12:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c", size = 2052078, upload-time = "2026-04-17T09:10:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1", size = 2016941, upload-time = "2026-04-17T09:12:34.447Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[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]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { 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 = "realesrgan" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "basicsr" }, + { name = "facexlib" }, + { name = "gfpgan" }, + { name = "numpy" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "torchvision", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/40/4af728ed9c48ac65634976535f36afd421de39315920e5f740049a6524a6/realesrgan-0.3.0.tar.gz", hash = "sha256:0d36da96ab9f447071606e91f502ccdfb08f80cc82ee4f8caf720c7745ccec7e", size = 3020534, upload-time = "2022-09-20T11:49:56.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/3e/e2f79917a04991b9237df264f7abab2b58cf94748e7acfb6677b55232ca1/realesrgan-0.3.0-py3-none-any.whl", hash = "sha256:59336c16c30dd5130eff350dd27424acb9b7281d18a6810130e265606c9a6088", size = 26012, upload-time = "2022-09-20T11:49:54.915Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[package]] +name = "scikit-image" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "lazy-loader" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "scipy" }, + { name = "tifffile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b4/2528bb43c67d48053a7a649a9666432dc307d66ba02e3a6d5c40f46655df/scikit_image-0.26.0.tar.gz", hash = "sha256:f5f970ab04efad85c24714321fcc91613fcb64ef2a892a13167df2f3e59199fa", size = 22729739, upload-time = "2025-12-20T17:12:21.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/48/02357ffb2cca35640f33f2cfe054a4d6d5d7a229b88880a64f1e45c11f4e/scikit_image-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a2e852eccf41d2d322b8e60144e124802873a92b8d43a6f96331aa42888491c7", size = 12346329, upload-time = "2025-12-20T17:11:11.599Z" }, + { url = "https://files.pythonhosted.org/packages/67/b9/b792c577cea2c1e94cda83b135a656924fc57c428e8a6d302cd69aac1b60/scikit_image-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98329aab3bc87db352b9887f64ce8cdb8e75f7c2daa19927f2e121b797b678d5", size = 12031726, upload-time = "2025-12-20T17:11:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/9564250dfd65cb20404a611016db52afc6268b2b371cd19c7538ea47580f/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:915bb3ba66455cf8adac00dc8fdf18a4cd29656aec7ddd38cb4dda90289a6f21", size = 13094910, upload-time = "2025-12-20T17:11:16.2Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b8/0d8eeb5a9fd7d34ba84f8a55753a0a3e2b5b51b2a5a0ade648a8db4a62f7/scikit_image-0.26.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b36ab5e778bf50af5ff386c3ac508027dc3aaeccf2161bdf96bde6848f44d21b", size = 13660939, upload-time = "2025-12-20T17:11:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d6/91d8973584d4793d4c1a847d388e34ef1218d835eeddecfc9108d735b467/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09bad6a5d5949c7896c8347424c4cca899f1d11668030e5548813ab9c2865dcb", size = 14138938, upload-time = "2025-12-20T17:11:20.919Z" }, + { url = "https://files.pythonhosted.org/packages/39/9a/7e15d8dc10d6bbf212195fb39bdeb7f226c46dd53f9c63c312e111e2e175/scikit_image-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:aeb14db1ed09ad4bee4ceb9e635547a8d5f3549be67fc6c768c7f923e027e6cd", size = 14752243, upload-time = "2025-12-20T17:11:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/8f/58/2b11b933097bc427e42b4a8b15f7de8f24f2bac1fd2779d2aea1431b2c31/scikit_image-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:ac529eb9dbd5954f9aaa2e3fe9a3fd9661bfe24e134c688587d811a0233127f1", size = 11906770, upload-time = "2025-12-20T17:11:25.297Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ec/96941474a18a04b69b6f6562a5bd79bd68049fa3728d3b350976eccb8b93/scikit_image-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2d211bc355f59725efdcae699b93b30348a19416cc9e017f7b2fb599faf7219", size = 11342506, upload-time = "2025-12-20T17:11:27.399Z" }, + { url = "https://files.pythonhosted.org/packages/03/e5/c1a9962b0cf1952f42d32b4a2e48eed520320dbc4d2ff0b981c6fa508b6b/scikit_image-0.26.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9eefb4adad066da408a7601c4c24b07af3b472d90e08c3e7483d4e9e829d8c49", size = 12663278, upload-time = "2025-12-20T17:11:29.358Z" }, + { url = "https://files.pythonhosted.org/packages/ae/97/c1a276a59ce8e4e24482d65c1a3940d69c6b3873279193b7ebd04e5ee56b/scikit_image-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6caec76e16c970c528d15d1c757363334d5cb3069f9cea93d2bead31820511f3", size = 12405142, upload-time = "2025-12-20T17:11:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4a/f1cbd1357caef6c7993f7efd514d6e53d8fd6f7fe01c4714d51614c53289/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a07200fe09b9d99fcdab959859fe0f7db8df6333d6204344425d476850ce3604", size = 12942086, upload-time = "2025-12-20T17:11:33.683Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/74d9fb87c5655bd64cf00b0c44dc3d6206d9002e5f6ba1c9aeb13236f6bf/scikit_image-0.26.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92242351bccf391fc5df2d1529d15470019496d2498d615beb68da85fe7fdf37", size = 13265667, upload-time = "2025-12-20T17:11:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/faddc2413ae98d863f6fa2e3e14da4467dd38e788e1c23346cf1a2b06b97/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:52c496f75a7e45844d951557f13c08c81487c6a1da2e3c9c8a39fcde958e02cc", size = 14001966, upload-time = "2025-12-20T17:11:38.55Z" }, + { url = "https://files.pythonhosted.org/packages/02/94/9f46966fa042b5d57c8cd641045372b4e0df0047dd400e77ea9952674110/scikit_image-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:20ef4a155e2e78b8ab973998e04d8a361d49d719e65412405f4dadd9155a61d9", size = 14359526, upload-time = "2025-12-20T17:11:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b4/2840fe38f10057f40b1c9f8fb98a187a370936bf144a4ac23452c5ef1baf/scikit_image-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:c9087cf7d0e7f33ab5c46d2068d86d785e70b05400a891f73a13400f1e1faf6a", size = 12287629, upload-time = "2025-12-20T17:11:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/22/ba/73b6ca70796e71f83ab222690e35a79612f0117e5aaf167151b7d46f5f2c/scikit_image-0.26.0-cp313-cp313t-win_arm64.whl", hash = "sha256:27d58bc8b2acd351f972c6508c1b557cfed80299826080a4d803dd29c51b707e", size = 11647755, upload-time = "2025-12-20T17:11:45.279Z" }, + { url = "https://files.pythonhosted.org/packages/51/44/6b744f92b37ae2833fd423cce8f806d2368859ec325a699dc30389e090b9/scikit_image-0.26.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:63af3d3a26125f796f01052052f86806da5b5e54c6abef152edb752683075a9c", size = 12365810, upload-time = "2025-12-20T17:11:47.357Z" }, + { url = "https://files.pythonhosted.org/packages/40/f5/83590d9355191f86ac663420fec741b82cc547a4afe7c4c1d986bf46e4db/scikit_image-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ce00600cd70d4562ed59f80523e18cdcc1fae0e10676498a01f73c255774aefd", size = 12075717, upload-time = "2025-12-20T17:11:49.483Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/253e7cf5aee6190459fe136c614e2cbccc562deceb4af96e0863f1b8ee29/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6381edf972b32e4f54085449afde64365a57316637496c1325a736987083e2ab", size = 13161520, upload-time = "2025-12-20T17:11:51.58Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/cec6a3cbaadfdcc02bd6ff02f3abfe09eaa7f4d4e0a525a1e3a3f4bce49c/scikit_image-0.26.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6624a76c6085218248154cc7e1500e6b488edcd9499004dd0d35040607d7505", size = 13684340, upload-time = "2025-12-20T17:11:53.708Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0d/39a776f675d24164b3a267aa0db9f677a4cb20127660d8bf4fd7fef66817/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f775f0e420faac9c2aa6757135f4eb468fb7b70e0b67fa77a5e79be3c30ee331", size = 14203839, upload-time = "2025-12-20T17:11:55.89Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/2514df226bbcedfe9b2caafa1ba7bc87231a0c339066981b182b08340e06/scikit_image-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede4d6d255cc5da9faeb2f9ba7fedbc990abbc652db429f40a16b22e770bb578", size = 14770021, upload-time = "2025-12-20T17:11:58.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5b/0671dc91c0c79340c3fe202f0549c7d3681eb7640fe34ab68a5f090a7c7f/scikit_image-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:0660b83968c15293fd9135e8d860053ee19500d52bf55ca4fb09de595a1af650", size = 12023490, upload-time = "2025-12-20T17:12:00.013Z" }, + { url = "https://files.pythonhosted.org/packages/65/08/7c4cb59f91721f3de07719085212a0b3962e3e3f2d1818cbac4eeb1ea53e/scikit_image-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:b8d14d3181c21c11170477a42542c1addc7072a90b986675a71266ad17abc37f", size = 11473782, upload-time = "2025-12-20T17:12:01.983Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/65c4258137acef3d73cb561ac55512eacd7b30bb4f4a11474cad526bc5db/scikit_image-0.26.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cde0bbd57e6795eba83cb10f71a677f7239271121dc950bc060482834a668ad1", size = 12686060, upload-time = "2025-12-20T17:12:03.886Z" }, + { url = "https://files.pythonhosted.org/packages/e7/32/76971f8727b87f1420a962406388a50e26667c31756126444baf6668f559/scikit_image-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:163e9afb5b879562b9aeda0dd45208a35316f26cc7a3aed54fd601604e5cf46f", size = 12422628, upload-time = "2025-12-20T17:12:05.921Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/996febd39f757c40ee7b01cdb861867327e5c8e5f595a634e8201462d958/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724f79fd9b6cb6f4a37864fe09f81f9f5d5b9646b6868109e1b100d1a7019e59", size = 12962369, upload-time = "2025-12-20T17:12:07.912Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/612d354f946c9600e7dea012723c11d47e8d455384e530f6daaaeb9bf62c/scikit_image-0.26.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3268f13310e6857508bd87202620df996199a016a1d281b309441d227c822394", size = 13272431, upload-time = "2025-12-20T17:12:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/26c00b466e06055a086de2c6e2145fe189ccdc9a1d11ccc7de020f2591ad/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fac96a1f9b06cd771cbbb3cd96c5332f36d4efd839b1d8b053f79e5887acde62", size = 14016362, upload-time = "2025-12-20T17:12:12.793Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/00a90402e1775634043c2a0af8a3c76ad450866d9fa444efcc43b553ba2d/scikit_image-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c1e7bd342f43e7a97e571b3f03ba4c1293ea1a35c3f13f41efdc8a81c1dc8f2", size = 14364151, upload-time = "2025-12-20T17:12:14.909Z" }, + { url = "https://files.pythonhosted.org/packages/da/ca/918d8d306bd43beacff3b835c6d96fac0ae64c0857092f068b88db531a7c/scikit_image-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b702c3bb115e1dcf4abf5297429b5c90f2189655888cbed14921f3d26f81d3a4", size = 12413484, upload-time = "2025-12-20T17:12:17.046Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "setuptools" +version = "81.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, +] + +[[package]] +name = "shared-python" +version = "0.1.0" +source = { editable = "../shared/python" } +dependencies = [ + { name = "nats-py" }, + { name = "pydantic-settings" }, + { name = "pytest-asyncio" }, + { name = "structlog" }, +] + +[package.metadata] +requires-dist = [ + { name = "nats-py", specifier = ">=2.14.0" }, + { name = "pydantic-settings", specifier = ">=2.14.0" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "structlog", specifier = ">=25.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyrefly", specifier = ">=0.62.0" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.15.11" }, + { name = "testcontainers", extras = ["nats"], specifier = ">=4.14.2" }, +] + +[[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]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tb-nightly" +version = "2.21.0a20251023" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "grpcio" }, + { name = "markdown" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "setuptools" }, + { name = "tensorboard-data-server" }, + { name = "werkzeug" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/a8/65f385e7d3e7e8489c030d22ca4c0c0a02d92b755e6e8873d84c7d8174bd/tb_nightly-2.21.0a20251023-py3-none-any.whl", hash = "sha256:369f8f7c160b87d15515a35b49f49ac3212ef0547ed20e4dee37cf0ea7079d28", size = 5525812, upload-time = "2025-10-23T12:24:52.947Z" }, +] + +[[package]] +name = "tensorboard-data-server" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/13/e503968fefabd4c6b2650af21e110aa8466fe21432cd7c43a84577a89438/tensorboard_data_server-0.7.2-py3-none-any.whl", hash = "sha256:7e0610d205889588983836ec05dc098e80f97b7e7bbff7e994ebb78f578d0ddb", size = 2356, upload-time = "2023-10-23T21:23:32.16Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/dabeaf902892922777492e1d253bb7e1264cadce3cea932f7ff599e53fea/tensorboard_data_server-0.7.2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:9fe5d24221b29625dbc7328b0436ca7fc1c23de4acf4d272f1180856e32f9f60", size = 4823598, upload-time = "2023-10-23T21:23:33.714Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, +] + +[[package]] +name = "testcontainers" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, +] + +[package.optional-dependencies] +nats = [ + { name = "nats-py" }, +] + +[[package]] +name = "tifffile" +version = "2026.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/4a/e687f5957fead200faad58dbf9c9431a2bbb118040e96f5fb8a55f7ebc50/tifffile-2026.4.11.tar.gz", hash = "sha256:17758ff0c0d4db385792a083ad3ca51fcb0f4d942642f4d8f8bc1287fdcf17bc", size = 394956, upload-time = "2026-04-12T01:57:28.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/9f/74f110b4271ded519c7add4341cbabc824de26817ff1c345b3109df9e99c/tifffile-2026.4.11-py3-none-any.whl", hash = "sha256:9b94ffeddb39e97601af646345e8808f885773de01b299e480ed6d3a41509ec9", size = 248227, upload-time = "2026-04-12T01:57:26.969Z" }, +] + +[[package]] +name = "torch" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "filelock", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "fsspec", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "jinja2", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "networkx", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "setuptools", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "sympy", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "typing-extensions", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/89/5ea6722763acee56b045435fb84258db7375c48165ec8be7880ab2b281c5/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6debd97ccd3205bbb37eb806a9d8219e1139d15419982c09e23ef7d4369d18", size = 80606801, upload-time = "2026-03-23T18:10:18.649Z" }, + { url = "https://files.pythonhosted.org/packages/db/38/8ac78069621b8c2b4979c2f96dc8409ef5e9c4189f6aac629189a78677ca/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8b394322f49af4362d4f80e424bcaca7efcd049619af03a4cf4501520bdf0fb4", size = 80959574, upload-time = "2026-03-23T18:10:14.214Z" }, + { url = "https://files.pythonhosted.org/packages/26/0d/8603382f61abd0db35841148ddc1ffd607bf3100b11c6e1dab6d2fc44e72/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01018087326984a33b64e04c8cb5c2795f9120e0d775ada1f6638840227b04d7", size = 80573442, upload-time = "2026-03-23T18:09:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/4419098ed6d801750f26567b478fc185c3432e11e2cad712bc6b4c2ab0d0/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8245477871c3700d4370352ffec94b103cfcb737229445cf9946cddb7b2ca7cd", size = 80959460, upload-time = "2026-03-23T18:09:00.818Z" }, +] + +[[package]] +name = "torch" +version = "2.11.0+cu128" +source = { registry = "https://download.pytorch.org/whl/cu128" } +resolution-markers = [ + "sys_platform == 'linux' or sys_platform == 'win32'", +] +dependencies = [ + { name = "cuda-bindings", marker = "sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, + { name = "filelock", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "fsspec", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "jinja2", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "networkx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "nvidia-cudnn-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu12", marker = "sys_platform == 'linux'" }, + { name = "setuptools", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sympy", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "triton", marker = "sys_platform == 'linux'" }, + { name = "typing-extensions", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313-manylinux_2_28_aarch64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313-win_amd64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313t-manylinux_2_28_aarch64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313t-manylinux_2_28_x86_64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp313-cp313t-win_amd64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp314-cp314-manylinux_2_28_aarch64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp314-cp314-manylinux_2_28_x86_64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp314-cp314-win_amd64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp314-cp314t-manylinux_2_28_aarch64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp314-cp314t-manylinux_2_28_x86_64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torch-2.11.0%2Bcu128-cp314-cp314t-win_amd64.whl" }, +] + +[[package]] +name = "torchvision" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "sys_platform != 'linux' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "pillow", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/80/0762f77f53605d10c9477be39bb47722cc8e383bbbc2531471ce0e396c07/torchvision-0.26.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5d63dd43162691258b1b3529b9041bac7d54caa37eae0925f997108268cbf7c4", size = 1860809, upload-time = "2026-03-23T18:12:47.629Z" }, + { url = "https://files.pythonhosted.org/packages/66/28/b4ad0a723ed95b003454caffcc41894b34bd8379df340848cae2c33871de/torchvision-0.26.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:358fc4726d0c08615b6d83b3149854f11efb2a564ed1acb6fce882e151412d23", size = 1951973, upload-time = "2026-03-23T18:12:48.781Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c8/9bffa9c7f7bdf95b2a0a2dc535c290b9f1cc580c3fb3033ab1246ffffdeb/torchvision-0.26.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:eb61804eb9dbe88c5a2a6c4da8dec1d80d2d0a6f18c999c524e32266cb1ebcd3", size = 1860813, upload-time = "2026-03-23T18:12:39.636Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/1f0402ac55c2ae15651ff831957d083fe70b2d12282e72612a30ba601512/torchvision-0.26.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:b7d3e295624a28b3b1769228ce1345d94cf4d390dd31136766f76f2d20f718da", size = 1860826, upload-time = "2026-03-23T18:12:34.1Z" }, +] + +[[package]] +name = "torchvision" +version = "0.26.0+cu128" +source = { registry = "https://download.pytorch.org/whl/cu128" } +resolution-markers = [ + "sys_platform == 'linux' or sys_platform == 'win32'", +] +dependencies = [ + { name = "numpy", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pillow", marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, +] +wheels = [ + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c4a9cacd521f2a4df0bcd9d8e96704771b928f478f1f3067e4085bb53a1da298" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cb1f6184a7ba30fba40580e1a01a6604a86c55e79fdda187f40116ee680441ec" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313-win_amd64.whl", hash = "sha256:0232cb219927a52d6c98ff202f32d1cdf4802c2195a85fc1f1a0c1b0b4983a4d" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e594732552a8c2fee2ace9c6475c6c6904fc44ccca622ee6765a89a045416a44" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6168abc019803ac9e97efce27eafd2fdb33db04dcc54a86039537729e5047b29" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp313-cp313t-win_amd64.whl", hash = "sha256:367d42ea703844ecdb516e9d5eb09929012a58705d2622cf4e9e3c37f278cb85" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:b3865fa227661dd75b7b28c96d3d14e739bd08bf0614132758922fe0e7206f91" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:aac647c9130f1f25f5c8f5bca3d95cfd96bdfac93ab54529690b088e64e4fa64" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp314-cp314-win_amd64.whl", hash = "sha256:6319e1ba49c6f62ac9902f73d0eab207b8a4dc6b4d3392fe9edd9903fff1be0a" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e2ee9e16ee4518292694537fcbd20d2d27044e381d92b864f637e82795796a84" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:b5772c55bfda4377df8f1930d43c4e0231ef231b0228eade4b227c8d3ba6e34e" }, + { url = "https://download-r2.pytorch.org/whl/cu128/torchvision-0.26.0%2Bcu128-cp314-cp314t-win_amd64.whl", hash = "sha256:f160dc552a086244f7102c898f7be8ef46a41b36bce5ea80a4f2493cb30ca1fc" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "triton" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/12/34d71b350e89a204c2c7777a9bba0dcf2f19a5bfdd70b57c4dbc5ffd7154/triton-3.6.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448e02fe6dc898e9e5aa89cf0ee5c371e99df5aa5e8ad976a80b93334f3494fd", size = 176133521, upload-time = "2026-01-20T16:16:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/41b0c8033b503fd3cfcd12392cdd256945026a91ff02452bef40ec34bee7/triton-3.6.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1722e172d34e32abc3eb7711d0025bb69d7959ebea84e3b7f7a341cd7ed694d6", size = 176276087, upload-time = "2026-01-20T16:16:18.989Z" }, + { url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/5ecf0dcaa0f2fbbd4420f7ef227ee3cb172e91e5fede9d0ecaddc43363b4/triton-3.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5523241e7d1abca00f1d240949eebdd7c673b005edbbce0aca95b8191f1d43", size = 176138577, upload-time = "2026-01-20T16:16:25.426Z" }, + { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/db/56ee649cab5eaff4757541325aca81f52d02d4a7cd3506776cad2451e060/triton-3.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b3a97e8ed304dfa9bd23bb41ca04cdf6b2e617d5e782a8653d616037a5d537d", size = 176274804, upload-time = "2026-01-20T16:16:31.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "video-upscaling" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "basicsr-fixed" }, + { name = "nats-py" }, + { name = "opencv-python-headless" }, + { name = "pydantic-settings" }, + { name = "realesrgan" }, + { name = "requests" }, + { name = "shared-python" }, + { name = "structlog" }, + { name = "torch", version = "2.11.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", version = "2.11.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "torchvision", version = "0.26.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torchvision", version = "0.26.0+cu128", source = { registry = "https://download.pytorch.org/whl/cu128" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "types-requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "testcontainers", extra = ["nats"] }, +] + +[package.metadata] +requires-dist = [ + { name = "basicsr-fixed" }, + { name = "nats-py" }, + { name = "opencv-python-headless" }, + { name = "pydantic-settings", specifier = ">=2.13.1" }, + { name = "realesrgan", specifier = ">=0.3.0" }, + { name = "requests" }, + { name = "shared-python", editable = "../shared/python" }, + { name = "structlog" }, + { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torch", marker = "sys_platform == 'linux' or sys_platform == 'win32'", index = "https://download.pytorch.org/whl/cu128" }, + { name = "torchvision", marker = "sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "torchvision", marker = "sys_platform == 'linux' or sys_platform == 'win32'", index = "https://download.pytorch.org/whl/cu128" }, + { name = "types-requests", specifier = ">=2.33.0.20260408" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.25.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, + { name = "ruff", specifier = ">=0.15.11" }, + { name = "testcontainers", extras = ["nats"], specifier = ">=4.0.0" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + +[[package]] +name = "yapf" +version = "0.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" }, +] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2793967..75b9006 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,64 +1,72 @@ -import { useRef } from 'react' -import FileUploadDropZone from './components/fileUploadDropZone' -import Header from './components/header' +import { useRef, useState } from 'react' +import FileUploadDropZone from './components/videoUploadPanel' import { Toaster } from 'sonner' import { useVideoQueueStore } from './state/videoQueue' import { useUploadQueue } from './hooks/useUploadQueue' import { useJobPolling } from './hooks/useJobPolling' -import type { UploadedVideo } from './types/video' -import VideoHeader from './components/videoHeader' -import VideoUploadButton from './components/videoUploadButton' -import UploadVideoList from './components/uploadVideosList' -import ProcessedVideoList from './components/processedVideoList' +import type { ProcessingType, UploadedVideo } from './types/video' +import Sidebar from './components/navigation' +import VideoPanel from './components/videoDisplayPanel' +import { defaultResolution, getVideoResolution } from './utils/videoResolution' let nextId = 0 function App() { const { uploadedVideos, processedVideos, addVideos, removeProcessedVideo } = useVideoQueueStore() - const { removeUploadedVideo } = useUploadQueue() + const [ activeFeature, setActiveFeature] = useState('Transcode') + const { removeUploadedVideo } = useUploadQueue(activeFeature) const fileMap = useRef>(new Map()) useJobPolling() - function handleFiles(files: File[]) { - const newVideos: UploadedVideo[] = files.map(file => { + async function handleFiles(files: File[]) { + const newVideos: UploadedVideo[] = await Promise.all(files.map(async file => { const id = nextId++ fileMap.current.set(id, file) + + let sourceHeight = 0 + try { + const detected = await getVideoResolution(file) + sourceHeight = detected.height + } catch { /* leave as 0 — all resolutions will be shown */ } + + const resolution = defaultResolution(activeFeature, sourceHeight) + return { id, name: file.name, size: file.size, - resolution: '1080p', + resolution: resolution, + sourceHeight, status: 'pending' as const, uploadProgress: 0, - jobId: null + jobId: null, } - }) - addVideos(newVideos) + })) + addVideos(activeFeature, newVideos) } - function handleRemove(id: number) { + function handleRemove(processingType: ProcessingType, id: number) { fileMap.current.delete(id) - removeUploadedVideo(id) + removeUploadedVideo(processingType, id) } return ( <> -
-
- -
-
- - - -
-
- - -
-
-
+
+ +
+ + +
+
) } diff --git a/frontend/src/api/services/video.ts b/frontend/src/api/services/video.ts index 38ef0b2..898276a 100644 --- a/frontend/src/api/services/video.ts +++ b/frontend/src/api/services/video.ts @@ -6,6 +6,8 @@ export const VideoService = { upload: ( videoFile: File, targetResolution: string, + sourceResolution: string, + processingType: string, onProgress: (pct: number) => void ): { promise: Promise<{ job_id: string }>; abort: () => void} => { let xhr: XMLHttpRequest @@ -17,6 +19,8 @@ export const VideoService = { const formData = new FormData() formData.append("video", videoFile) formData.append("target_resolution", targetResolution) + formData.append("source_resolution", sourceResolution) + formData.append("process_type", processingType) xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png deleted file mode 100644 index cc51a3d..0000000 Binary files a/frontend/src/assets/hero.png and /dev/null differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/frontend/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx deleted file mode 100644 index 0d1800c..0000000 --- a/frontend/src/components/header.tsx +++ /dev/null @@ -1,45 +0,0 @@ -const Header = () => { - const usedGb = 12.4 - const totalGb = 50 - const pct = (usedGb / totalGb) * 100 - - return ( -
- - splice - - -
- - {/* Storage quota */} -
-
-
-
- - {usedGb} / {totalGb} GB - -
- -
- - - user@example.com - - - -
-
- ) -} - -export default Header diff --git a/frontend/src/components/navigation.tsx b/frontend/src/components/navigation.tsx new file mode 100644 index 0000000..5cb5c7e --- /dev/null +++ b/frontend/src/components/navigation.tsx @@ -0,0 +1,74 @@ +import { Clapperboard, Sparkles, Wind, ArrowLeftRight, Settings } from 'lucide-react' +import type { ProcessingType } from '../types/video' + +interface SidebarProps { + activeFeature: ProcessingType + onSelect: (feature: ProcessingType) => void +} + +interface NavEntry { + feature: ProcessingType + label: string + icon: React.ReactNode +} + +const NAV_ENTRIES: NavEntry[] = [ + { feature: 'Transcode', label: 'Transcode', icon: }, + { feature: 'Upscale', label: 'Upscale', icon: }, + { feature: 'Denoise', label: 'Denoise', icon: }, + { feature: 'Convert', label: 'Convert', icon: }, +] + +const Sidebar = ({ activeFeature, onSelect }: SidebarProps) => { + return ( + + ) +} + +export default Sidebar diff --git a/frontend/src/components/processedVideoList.tsx b/frontend/src/components/processedVideoList.tsx deleted file mode 100644 index df1013f..0000000 --- a/frontend/src/components/processedVideoList.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { CheckCheck, X } from "lucide-react" -import type { UploadedVideo } from "../types/video" -import { formatSize, truncateName } from "../utils/fileDisplay" -import VideoDownloadButton from "./videoDownloadButton" - - -const ProcessedVideoList = ({ processedVideos, onRemove}: { processedVideos: UploadedVideo[], onRemove: (id: number) => void}) => { - return ( -
    - {processedVideos.map(video => ( -
  • -
    - - - - {truncateName(video.name)} - - - - {formatSize(video.size)} - - - - - -
    -
  • - ))} -
- ) -} - -export default ProcessedVideoList \ No newline at end of file diff --git a/frontend/src/components/uploadVideosList.tsx b/frontend/src/components/uploadVideosList.tsx deleted file mode 100644 index 4b9c757..0000000 --- a/frontend/src/components/uploadVideosList.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { CheckCheck, Video, X } from "lucide-react" -import type { JobStatus, UploadedVideo } from "../types/video" -import { formatSize, truncateName } from "../utils/fileDisplay" -import { useVideoQueueStore } from "../state/videoQueue" -import VideoProgressBar from "./videoProgressBar" - -interface UploadVideoListProps { - videos: UploadedVideo[] - onRemove: (id: number) => void -} - -const RESOLUTIONS = ['480p', '720p', '1080p'] - -const UploadVideoList = ({ videos, onRemove }: UploadVideoListProps) => { - const { uploadedVideos, resetVideo, setResolution } = useVideoQueueStore() - - function StatusIcon({ status }: { status: JobStatus }) { - if (status === 'complete') return - if (status === 'error') return - return