diff --git a/.github/workflows/cc-catalog-svc.yaml b/.github/workflows/cc-catalog-svc.yaml index 074ec0a2715..ebfa4eb6173 100644 --- a/.github/workflows/cc-catalog-svc.yaml +++ b/.github/workflows/cc-catalog-svc.yaml @@ -206,6 +206,9 @@ jobs: build-args: | GITHUB_SHA=${{ github.sha }} GITHUB_REF=${{ github.ref }} + BAKE_GIT_MIRRORS=true + secrets: | + github_token=${{ secrets.GITHUB_TOKEN }} - name: Release summary if: needs.create-release.outputs.release_tag != '' diff --git a/cc-catalog-svc/Dockerfile b/cc-catalog-svc/Dockerfile index 17392e63d2e..bb25238b0c0 100644 --- a/cc-catalog-svc/Dockerfile +++ b/cc-catalog-svc/Dockerfile @@ -1,5 +1,6 @@ # cc-catalog-svc image. # +# syntax=docker/dockerfile:1.4 # Two layers worth of intent: # # 1. A small Python runtime (python:3.12-slim) running a single uvicorn @@ -10,11 +11,20 @@ # the de-facto tool for OCI image copy. We pin a specific version so # builds are reproducible. # +# Air-gap git mirrors: +# CI has outbound git access; the running container may not. When +# BAKE_GIT_MIRRORS=true (default in CI), the git-bake stage clones bare +# mirrors into /opt/cc-catalog/git — outside the /data volume so a PVC +# mount does not hide baked repos. Deploy with git.data_dir=/opt/cc-catalog/git +# and git.runtime_sync=false (see config-examples/config.airgap.yaml). +# # Read-only-rootfs friendly: the only writable path the service needs is # /data (SQLite DB + crane's auth cache). Mount a volume there in k8s. ARG PYTHON_VERSION=3.12-slim ARG CRANE_VERSION=v0.20.2 +ARG BAKE_GIT_MIRRORS=true +ARG BAKE_CONFIG=config-examples/config.bake.yaml # ----------------------------------------------------------------------------- # Stage 1: fetch crane. Doing this in a tiny scratch-ish stage keeps the @@ -39,7 +49,39 @@ RUN set -eux; \ /usr/local/bin/crane version # ----------------------------------------------------------------------------- -# Stage 2: runtime image. +# Stage 2: bake git mirrors (CI only — needs outbound network). +# ----------------------------------------------------------------------------- +FROM python:${PYTHON_VERSION} AS git-bake +ARG BAKE_GIT_MIRRORS +ARG BAKE_CONFIG +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /build +COPY pyproject.toml README.md ./ +COPY app ./app +COPY scripts ./scripts +COPY config-examples/config.bake.yaml ./config-examples/config.bake.yaml +RUN pip install --no-cache-dir . +RUN mkdir -p /opt/cc-catalog/git +RUN --mount=type=secret,id=github_token \ + if [ "${BAKE_GIT_MIRRORS}" = "true" ]; then \ + export GITHUB_TOKEN="$(cat /run/secrets/github_token 2>/dev/null || true)"; \ + python scripts/bake_git_mirrors.py \ + --config "${BAKE_CONFIG}" \ + --dest /opt/cc-catalog/git; \ + baked="$(find /opt/cc-catalog/git -mindepth 1 -maxdepth 1 -type d -name '*.git' | wc -l)"; \ + if [ "${baked}" -eq 0 ]; then \ + echo "ERROR: BAKE_GIT_MIRRORS=true but no *.git dirs under /opt/cc-catalog/git" >&2; \ + exit 1; \ + fi; \ + echo "git bake: ${baked} bare repo(s) in /opt/cc-catalog/git"; \ + else \ + echo "BAKE_GIT_MIRRORS=false; skipping git mirror bake"; \ + fi + +# ----------------------------------------------------------------------------- +# Stage 3: runtime image. # ----------------------------------------------------------------------------- FROM python:${PYTHON_VERSION} AS runtime @@ -52,10 +94,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ # operator instinct carries over. RUN groupadd --system --gid 1000 catalog \ && useradd --system --uid 1000 --gid 1000 --home /home/catalog --create-home catalog \ - && mkdir -p /app /data \ - && chown -R catalog:catalog /app /data + && mkdir -p /app /data /data/git /opt/cc-catalog/git \ + && chown -R catalog:catalog /app /data /opt/cc-catalog COPY --from=crane /usr/local/bin/crane /usr/local/bin/crane +COPY --from=git-bake /opt/cc-catalog/git /opt/cc-catalog/git + +# git binary kept for optional runtime_sync in connected environments. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* \ + && chown -R catalog:catalog /opt/cc-catalog/git WORKDIR /app # README.md is referenced from pyproject.toml's `readme = "README.md"`, so diff --git a/cc-catalog-svc/README.md b/cc-catalog-svc/README.md index 773e2b4b58a..c40b968d547 100644 --- a/cc-catalog-svc/README.md +++ b/cc-catalog-svc/README.md @@ -142,6 +142,15 @@ Admin (writes): |---|---| | POST | `/api/v1/admin/reload-config` | | POST | `/api/v1/admin/sync-catalog` | +| POST | `/api/v1/admin/sync-git?allow_runtime_sync=` | + +Git mirror hosting (read-only — see [docs/GIT_MIRROR.md](docs/GIT_MIRROR.md)): + +| Method | Path | +|---|---| +| GET | `/api/v1/git/repos` | +| GET | `/api/v1/git/repos/{slug}` | +| GET/POST | `/git/{slug}.git/info/refs`, `/git/{slug}.git/git-upload-pack` (smart HTTP) | Health: @@ -212,5 +221,6 @@ without network, without Postgres. - [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — design rationale, HA, schema - [docs/JFROG.md](docs/JFROG.md) — JFrog destination operator guide +- [docs/GIT_MIRROR.md](docs/GIT_MIRROR.md) — git mirror hosting for air-gap deployments - [docs/PAPI_INTEGRATION.md](docs/PAPI_INTEGRATION.md) — how PAPI consumes the catalog and the `?destination=` extension - `cc-registry-v2/docs/CCV.md` — the contract this service is compatible with (in the sibling project) diff --git a/cc-catalog-svc/app/config.py b/cc-catalog-svc/app/config.py index 3a21fb3a419..000da11ad5f 100644 --- a/cc-catalog-svc/app/config.py +++ b/cc-catalog-svc/app/config.py @@ -276,6 +276,100 @@ class SchedulerConfig(BaseModel): mirror_poll_minutes: int = Field(5, ge=1, le=1440) mirror_workers: int = Field(2, ge=1, le=32) per_job_timeout_seconds: int = Field(600, ge=10, le=3600) + git_sync_minutes: int = Field( + 60, + ge=1, + le=1440, + description="How often to fetch upstream git repos into local mirrors.", + ) + + +class GitAuth(BaseModel): + """Auth for fetching upstream git repos (GitHub PAT, etc.).""" + + token_env: Optional[str] = Field( + None, + description="Env var holding a Bearer token (GitHub PAT, etc.).", + ) + user_env: Optional[str] = Field( + None, + description="Env var holding HTTP Basic username.", + ) + pass_env: Optional[str] = Field( + None, + description="Env var holding HTTP Basic password or token-as-password.", + ) + + @model_validator(mode="after") + def _exactly_one_or_none(self) -> "GitAuth": + token = bool(self.token_env) + basic_any = bool(self.user_env) or bool(self.pass_env) + basic_both = bool(self.user_env) and bool(self.pass_env) + if token and basic_any: + raise ValueError("git.auth: set EITHER token_env OR user_env+pass_env, not both") + if basic_any and not basic_both: + raise ValueError("git.auth: user_env and pass_env must be set together") + return self + + +class GitServiceConfig(BaseModel): + """Local git mirror + smart HTTP service for air-gapped deployments. + + When enabled, cc-catalog-svc maintains bare mirror clones of each + configured CodeCollection ``git_url`` and serves them read-only at + ``mount_path``. Catalog API responses rewrite ``git_url`` to + ``public_base_url/.git`` once a mirror exists. + + Release images bake mirrors at Docker build time into + ``/opt/cc-catalog/git`` (outside the ``/data`` volume). Set + ``data_dir: /opt/cc-catalog/git`` and ``runtime_sync: false`` for + air-gapped deployments with no outbound git access. + """ + + enabled: bool = False + data_dir: str = Field( + "/opt/cc-catalog/git", + description=( + "Directory for bare mirror repos (.git). Defaults to " + "/opt/cc-catalog/git so release images with build-time baked " + "mirrors work out of the box. Override to a writable path on " + "the data PVC (e.g. /data/git) if you want runtime_sync to " + "persist fetched objects across pod restarts." + ), + ) + mount_path: str = Field( + "/git", + description="HTTP path prefix for git smart HTTP (clone URL path).", + ) + public_base_url: Optional[str] = Field( + None, + description=( + "External base URL for clone commands, e.g. " + "https://cc-catalog.example.com/git. Omit to keep upstream git_url " + "in catalog responses even when mirrors exist." + ), + ) + runtime_sync: bool = Field( + True, + description=( + "When false, skip scheduled/admin background fetch from upstream. " + "Use with build-time baked mirrors in air-gapped environments." + ), + ) + auth: GitAuth = Field(default_factory=GitAuth) + codecollections: list[str] = Field( + default_factory=list, + description=("Slugs to mirror. Empty = every CC with a git_url from sources."), + ) + clone_timeout_seconds: int = Field(900, ge=30, le=7200) + fetch_timeout_seconds: int = Field(600, ge=30, le=3600) + + @field_validator("auth", mode="before") + @classmethod + def _none_to_empty_auth(cls, v): + if v is None: + return {} + return v class CatalogAPIConfig(BaseModel): @@ -304,10 +398,11 @@ class AppConfig(BaseModel): storage: StorageConfig = Field(default_factory=StorageConfig) catalog_api: CatalogAPIConfig = Field(default_factory=CatalogAPIConfig) scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig) + git: GitServiceConfig = Field(default_factory=GitServiceConfig) sources: list[SourceConfig] = Field(default_factory=list) destinations: list[DestinationConfig] = Field(default_factory=list) - @field_validator("storage", "catalog_api", "scheduler", mode="before") + @field_validator("storage", "catalog_api", "scheduler", "git", mode="before") @classmethod def _none_to_default(cls, v, info): # `key:` in YAML => None; treat as "use defaults". diff --git a/cc-catalog-svc/app/db.py b/cc-catalog-svc/app/db.py index 520d78c968b..2b9452a4acd 100644 --- a/cc-catalog-svc/app/db.py +++ b/cc-catalog-svc/app/db.py @@ -23,7 +23,7 @@ from contextlib import contextmanager from typing import Iterator -from sqlalchemy import create_engine, event +from sqlalchemy import create_engine, event, inspect, text from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker @@ -32,6 +32,18 @@ logger = logging.getLogger(__name__) +# Columns added after the initial schema cut. ``init_db`` ensures these +# exist on already-deployed databases without requiring a full Alembic +# pipeline. Format: {table_name: {column_name: SQL type for ADD COLUMN}}. +# Keep types portable across sqlite + postgres (no ``SERIAL`` etc.). +_LEGACY_COLUMN_ADDITIONS: dict[str, dict[str, str]] = { + "codecollections": { + "git_head_commit": "VARCHAR(80)", + "git_last_synced": "TIMESTAMP", + "git_last_sync_error": "TEXT", + }, +} + _engine: Engine | None = None _SessionLocal: sessionmaker[Session] | None = None @@ -79,17 +91,49 @@ def get_session_factory() -> sessionmaker[Session]: def init_db() -> None: - """Create all tables. Idempotent. - - We intentionally use `Base.metadata.create_all` rather than Alembic - for the first cut: the schema is small (5 tables), single-writer - (the scheduler), and the service is greenfield. When the schema - starts evolving we'll add Alembic; until then `create_all` keeps the - bootstrap path trivial. + """Create all tables and apply lightweight in-place migrations. + + We intentionally use ``Base.metadata.create_all`` rather than + Alembic for the first cut: the schema is small (5 tables), + single-writer (the scheduler), and the service is greenfield. When + the schema starts evolving in earnest we'll add Alembic. + + ``create_all`` only creates *missing* tables, so any column added + to an existing table after the initial release would silently fail + on upgrade. ``_apply_legacy_column_additions`` patches that gap by + issuing ``ALTER TABLE ... ADD COLUMN IF NOT EXISTS`` (sqlite + pg + compatible) for every column registered in + ``_LEGACY_COLUMN_ADDITIONS``. """ engine = get_engine() logger.info("initializing schema on %s", _safe_dsn(str(engine.url))) Base.metadata.create_all(engine) + _apply_legacy_column_additions(engine) + + +def _apply_legacy_column_additions(engine: Engine) -> None: + """Idempotently add columns introduced after the initial schema.""" + inspector = inspect(engine) + existing_tables = set(inspector.get_table_names()) + for table_name, columns in _LEGACY_COLUMN_ADDITIONS.items(): + if table_name not in existing_tables: + # create_all just made it, so every column is already there. + continue + present = {c["name"] for c in inspector.get_columns(table_name)} + missing = {name: ddl for name, ddl in columns.items() if name not in present} + if not missing: + continue + with engine.begin() as conn: + for col_name, col_ddl in missing.items(): + logger.info( + "init_db: adding missing column %s.%s (%s)", + table_name, + col_name, + col_ddl, + ) + conn.execute( + text(f'ALTER TABLE {table_name} ADD COLUMN {col_name} {col_ddl}') + ) @contextmanager diff --git a/cc-catalog-svc/app/git_http/__init__.py b/cc-catalog-svc/app/git_http/__init__.py new file mode 100644 index 00000000000..3889994fec0 --- /dev/null +++ b/cc-catalog-svc/app/git_http/__init__.py @@ -0,0 +1,17 @@ +"""Git smart HTTP serving for mirrored bare repositories.""" + +from app.git_http.server import ( + is_valid_slug, + list_bare_repo_slugs, + make_git_wsgi_app, + repo_bare_path, + repo_exists, +) + +__all__ = [ + "is_valid_slug", + "list_bare_repo_slugs", + "make_git_wsgi_app", + "repo_bare_path", + "repo_exists", +] diff --git a/cc-catalog-svc/app/git_http/server.py b/cc-catalog-svc/app/git_http/server.py new file mode 100644 index 00000000000..62a3bb71e8b --- /dev/null +++ b/cc-catalog-svc/app/git_http/server.py @@ -0,0 +1,304 @@ +""" +Git smart HTTP via ``git http-backend``. + +Mirrored bare repos live at ``/.git``. This module maps +them to a WSGI app suitable for mounting under FastAPI (e.g. ``/git``). + +We delegate to the native ``git http-backend`` CGI rather than Dulwich +so clients can use shallow fetch (``--depth=N --tags``), which the +platform's gitget relies on. Each request spawns ``git http-backend``, +which reads ``GIT_PROJECT_ROOT`` directly from disk — so mirrors created +or refreshed after process start are served immediately, no restart +needed. + +Consumers clone with:: + + git clone https:///git/.git + +Security notes: + * Slugs are validated against ``_VALID_SLUG_RE`` before being joined + onto ``data_dir``. This blocks ``..`` segments, slashes, and other + path-escape tricks. + * When ``allowed_slugs`` is supplied, only those repos are served. + Any other ``*.git`` directory under ``data_dir`` returns 404. This + keeps leftover or operator-staged mirrors from being unintentionally + exposed over unauthenticated HTTP. + * Response bytes from ``git http-backend`` are streamed to the client + via the WSGI ``write`` callback in 64 KiB chunks; we never buffer + a full packfile in memory. +""" + +from __future__ import annotations + +import logging +import os +import re +import subprocess +from typing import Callable, Iterable, Optional + +logger = logging.getLogger(__name__) + +# Slugs are stable CodeCollection identifiers. Anchored, ascii-only, and +# no path separators / dots so we can join straight onto ``data_dir`` +# without worrying about traversal. +_VALID_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$") + +# Chunk size for streaming the CGI response body back over WSGI. 64 KiB +# matches what git's own smart-HTTP server uses. +_STREAM_CHUNK_BYTES = 64 * 1024 + +# Only ``info/refs`` and the two smart-HTTP service endpoints reach the +# git CGI. Anything else returns 404 so we don't accidentally expose +# raw refs / loose objects under unauthenticated HTTP. +_PATH_RE = re.compile( + r"^/(?P[A-Za-z0-9][A-Za-z0-9._-]{0,199})\.git" + r"(?P/info/refs|/git-upload-pack|/git-receive-pack|/HEAD)$" +) + + +def is_valid_slug(slug: str) -> bool: + """Return True if ``slug`` is safe to join onto ``data_dir``.""" + return bool(slug) and bool(_VALID_SLUG_RE.match(slug)) + + +def repo_bare_path(data_dir: str, slug: str) -> str: + """Return the on-disk path for ``.git`` under ``data_dir``. + + Raises ``ValueError`` if ``slug`` contains characters that could + escape ``data_dir`` (path separators, ``..`` segments, etc.). + """ + if not is_valid_slug(slug): + raise ValueError(f"invalid git mirror slug: {slug!r}") + return os.path.join(data_dir, f"{slug}.git") + + +def repo_exists(data_dir: str, slug: str) -> bool: + """True if ``/.git`` looks like a usable bare repo.""" + if not is_valid_slug(slug): + return False + path = repo_bare_path(data_dir, slug) + return os.path.isdir(path) and os.path.isfile(os.path.join(path, "HEAD")) + + +def list_bare_repo_slugs(data_dir: str) -> list[str]: + """Return sorted slugs for bare repos under ``data_dir``. + + Quietly skips entries with invalid slug syntax so operator-staged + directories with funny names can't poison the listing. + """ + if not os.path.isdir(data_dir): + return [] + slugs: list[str] = [] + for name in sorted(os.listdir(data_dir)): + if not name.endswith(".git"): + continue + slug = name[:-4] + if not is_valid_slug(slug): + logger.warning("git HTTP: skipping invalid slug %r under %s", slug, data_dir) + continue + path = os.path.join(data_dir, name) + if os.path.isdir(path) and os.path.isfile(os.path.join(path, "HEAD")): + slugs.append(slug) + return slugs + + +def _parse_cgi_response(raw: bytes) -> tuple[str, list[tuple[str, str]], bytes]: + """Split ``git http-backend`` CGI stdout into status, headers, body.""" + sep = raw.find(b"\r\n\r\n") + sep_len = 4 + if sep == -1: + sep = raw.find(b"\n\n") + sep_len = 2 + if sep == -1: + return "500 Internal Server Error", [("Content-Type", "text/plain")], raw + + header_block = raw[:sep].decode("latin-1") + body = raw[sep + sep_len :] + status = "200 OK" + headers: list[tuple[str, str]] = [] + for line in header_block.splitlines(): + if not line.strip(): + continue + if line.lower().startswith("status:"): + status = line.split(":", 1)[1].strip() + elif ":" in line: + name, value = line.split(":", 1) + headers.append((name.strip(), value.strip())) + return status, headers, body + + +def _git_http_backend_environ(data_dir: str, environ: dict) -> dict[str, str]: + """Build CGI environment for ``git http-backend`` from a WSGI environ.""" + cmd_env = os.environ.copy() + cmd_env["GIT_HTTP_EXPORT_ALL"] = "1" + cmd_env["GIT_PROJECT_ROOT"] = data_dir + cmd_env["REQUEST_METHOD"] = environ.get("REQUEST_METHOD", "GET") + cmd_env["PATH_INFO"] = environ.get("PATH_INFO", "") + cmd_env["QUERY_STRING"] = environ.get("QUERY_STRING", "") + cmd_env["SERVER_NAME"] = environ.get("SERVER_NAME", "localhost") + cmd_env["SERVER_PORT"] = str(environ.get("SERVER_PORT", "80")) + cmd_env["SERVER_PROTOCOL"] = environ.get("SERVER_PROTOCOL", "HTTP/1.1") + + for key, value in environ.items(): + if key.startswith("HTTP_") or key in ("CONTENT_TYPE", "CONTENT_LENGTH"): + cmd_env[key] = value + return cmd_env + + +def _stream_body(stdout, write: Callable[[bytes], None]) -> None: + """Pump stdout chunks straight to the WSGI ``write`` callback.""" + while True: + chunk = stdout.read(_STREAM_CHUNK_BYTES) + if not chunk: + return + write(chunk) + + +def _read_cgi_header(stdout) -> tuple[bytes, bytes]: + """Read until the CGI header/body separator. Returns (headers, leftover).""" + buf = bytearray() + while True: + chunk = stdout.read(1) + if not chunk: + return bytes(buf), b"" + buf.extend(chunk) + for sep in (b"\r\n\r\n", b"\n\n"): + idx = buf.find(sep) + if idx != -1: + return bytes(buf[:idx]), bytes(buf[idx + len(sep) :]) + + +def make_git_wsgi_app( + data_dir: str, + *, + allowed_slugs: Optional[Iterable[str]] = None, +) -> Callable: + """Build a WSGI app that serves bare repos under ``data_dir``. + + Args: + data_dir: Directory containing ``.git`` bare repos. + allowed_slugs: If provided, only these slugs are served (404 for + anything else under ``data_dir``). Pass ``None`` to serve + every valid slug discovered on disk — typically only useful + for local dev / tests. + """ + allowed: Optional[frozenset[str]] + if allowed_slugs is None: + allowed = None + else: + allowed = frozenset(s for s in allowed_slugs if is_valid_slug(s)) + + if allowed is None: + logger.info( + "git HTTP: serving %d repo(s) from %s via git http-backend (UNRESTRICTED)", + len(list_bare_repo_slugs(data_dir)), + data_dir, + ) + else: + logger.info( + "git HTTP: serving %d allowed slug(s) from %s via git http-backend", + len(allowed), + data_dir, + ) + + def app(environ, start_response): + path = environ.get("PATH_INFO", "") + match = _PATH_RE.match(path) + if not match: + return _send_not_found(start_response, "not a smart git HTTP path") + + slug = match.group("slug") + if allowed is not None and slug not in allowed: + logger.info("git HTTP: 404 for unallowed slug %r", slug) + return _send_not_found(start_response, "unknown git mirror") + + if not repo_exists(data_dir, slug): + return _send_not_found(start_response, "git mirror not found on disk") + + cmd_env = _git_http_backend_environ(data_dir, environ) + body_in = b"" + if environ.get("REQUEST_METHOD") in ("POST", "PUT", "PATCH"): + body_in = environ["wsgi.input"].read() + if body_in: + cmd_env["CONTENT_LENGTH"] = str(len(body_in)) + + try: + proc = subprocess.Popen( # noqa: S603 - git is trusted CGI + ["git", "http-backend"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=cmd_env, + ) + except FileNotFoundError: + logger.exception("git binary not found") + start_response("500 Internal Server Error", [("Content-Type", "text/plain")]) + return [b"git http-backend unavailable: git binary not found"] + + try: + if body_in: + proc.stdin.write(body_in) + proc.stdin.close() + + header_block, leftover = _read_cgi_header(proc.stdout) + if not header_block: + stderr = proc.stderr.read() + proc.wait() + logger.error( + "git http-backend produced no output (rc=%s path=%s): %s", + proc.returncode, + path, + stderr.decode("utf-8", errors="replace")[:2000], + ) + start_response("500 Internal Server Error", [("Content-Type", "text/plain")]) + return [stderr or b"git http-backend failed"] + + status, headers, _ = _parse_cgi_response(header_block + b"\r\n\r\n") + write = start_response(status, headers) + if write: + if leftover: + write(leftover) + _stream_body(proc.stdout, write) + proc.wait() + _log_proc_stderr(proc, path) + return [] + + # Fallback path: caller's WSGI stack didn't return a ``write`` + # callable. Buffer the body (size is bounded by what a single + # client requested anyway). + body_chunks: list[bytes] = [leftover] if leftover else [] + while True: + chunk = proc.stdout.read(_STREAM_CHUNK_BYTES) + if not chunk: + break + body_chunks.append(chunk) + proc.wait() + _log_proc_stderr(proc, path) + return body_chunks + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + + return app + + +def _send_not_found(start_response, message: str) -> list[bytes]: + start_response("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")]) + return [message.encode("utf-8")] + + +def _log_proc_stderr(proc: subprocess.Popen, path: str) -> None: + if proc.returncode == 0: + return + try: + stderr = proc.stderr.read() if proc.stderr else b"" + except Exception: # pragma: no cover - best-effort logging + return + if stderr: + logger.warning( + "git http-backend exited rc=%s for %s: %s", + proc.returncode, + path, + stderr.decode("utf-8", errors="replace")[:2000], + ) diff --git a/cc-catalog-svc/app/main.py b/cc-catalog-svc/app/main.py index 4e19aaeb47b..7d563be08de 100644 --- a/cc-catalog-svc/app/main.py +++ b/cc-catalog-svc/app/main.py @@ -16,6 +16,7 @@ scheduler. Useful when running 2+ API replicas behind a load balancer and a single dedicated scheduler pod. """ + from __future__ import annotations import logging @@ -25,9 +26,9 @@ from fastapi import FastAPI from app import __version__ -from app.config import get_settings, load_config +from app.config import get_settings, load_config, get_config from app.db import get_engine, init_db -from app.routers import admin, catalog, health, mirror +from app.routers import admin, catalog, git, health, mirror from app.scheduler import start_scheduler, stop_scheduler @@ -48,8 +49,44 @@ async def lifespan(app: FastAPI): load_config() init_db() + cfg = get_config() + if cfg.git.enabled: + from a2wsgi import WSGIMiddleware + + from app.git_http import make_git_wsgi_app + from app.services.git_mirror import populate_baked_head_commits, repos_to_sync + + mount = cfg.git.mount_path.rstrip("/") or "/git" + # Restrict HTTP serving to the slugs we know about so leftover + # bare repos on disk are never accidentally exposed. The set is + # derived from the loaded config (same source repos_to_sync uses). + allowed = {slug for slug, _ in repos_to_sync(cfg)} + # git http-backend streams packfiles via the WSGI write() callback. + app.mount( + mount, + WSGIMiddleware( + make_git_wsgi_app(cfg.git.data_dir, allowed_slugs=allowed), + send_queue_size=100, + ), + ) + logger.info( + "git smart HTTP mounted at %s (data_dir=%s, allowed=%d slug(s))", + mount, + cfg.git.data_dir, + len(allowed), + ) + # Best-effort: record HEAD commits for build-time baked mirrors + # so /api/v1/git/repos returns useful info in air-gap mode + # before any runtime sync has run. + try: + populate_baked_head_commits(cfg) + except Exception: + logger.exception("populate_baked_head_commits failed (continuing)") + if os.environ.get("CC_CATALOG_DISABLE_SCHEDULER", "").lower() not in ( - "1", "true", "yes", + "1", + "true", + "yes", ): start_scheduler() else: @@ -69,8 +106,9 @@ async def lifespan(app: FastAPI): title="cc-catalog-svc", description=( "Self-contained CodeCollection image catalog + mirror microservice. " - "PAPI-compatible catalog API plus a destination plugin system for " - "mirroring images into customer registries (JFrog Artifactory in v1)." + "PAPI-compatible catalog API, optional git mirror hosting for air-gapped " + "deployments, and a destination plugin system for mirroring images into " + "customer registries (JFrog Artifactory in v1)." ), version=__version__, lifespan=lifespan, @@ -82,6 +120,7 @@ async def lifespan(app: FastAPI): app.include_router(health.router) app.include_router(catalog.router) app.include_router(mirror.router) +app.include_router(git.router) app.include_router(admin.router) diff --git a/cc-catalog-svc/app/models.py b/cc-catalog-svc/app/models.py index 575b14ae3ba..e5255313955 100644 --- a/cc-catalog-svc/app/models.py +++ b/cc-catalog-svc/app/models.py @@ -14,6 +14,7 @@ so the catalog resolver logic ports cleanly. We don't reuse that model class because we don't want a hard dependency on the registry's package. """ + from __future__ import annotations from datetime import datetime @@ -60,11 +61,20 @@ class CodeCollection(Base): last_synced: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) last_sync_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + git_head_commit: Mapped[Optional[str]] = mapped_column(String(80), nullable=True) + git_last_synced: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + git_last_sync_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.now(), + DateTime, + nullable=False, + server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), ) refs: Mapped[list["ImageRef"]] = relationship( @@ -112,10 +122,15 @@ class ImageRef(Base): synced_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.now(), + DateTime, + nullable=False, + server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), ) cc: Mapped[CodeCollection] = relationship(back_populates="refs") @@ -150,10 +165,15 @@ class Destination(Base): last_sync_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.now(), + DateTime, + nullable=False, + server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), ) @@ -189,12 +209,16 @@ class MirrorTarget(Base): ) target_digest: Mapped[Optional[str]] = mapped_column(String(120), nullable=True) mirrored_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.now(), + DateTime, + nullable=False, + server_default=func.now(), ) __table_args__ = ( UniqueConstraint( - "cc_id", "destination_id", "source_image_tag", + "cc_id", + "destination_id", + "source_image_tag", name="uq_mirror_targets_cc_dest_tag", ), ) @@ -243,11 +267,11 @@ class MirrorJob(Base): ) created_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=func.now(), + DateTime, + nullable=False, + server_default=func.now(), ) started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - __table_args__ = ( - Index("ix_mirror_jobs_status_created", "status", "created_at"), - ) + __table_args__ = (Index("ix_mirror_jobs_status_created", "status", "created_at"),) diff --git a/cc-catalog-svc/app/routers/admin.py b/cc-catalog-svc/app/routers/admin.py index 544243f0f49..7c04cbf3ab7 100644 --- a/cc-catalog-svc/app/routers/admin.py +++ b/cc-catalog-svc/app/routers/admin.py @@ -3,17 +3,25 @@ POST /api/v1/admin/reload-config (admin) — re-read config.yaml POST /api/v1/admin/sync-catalog (admin) — run a catalog poll now + POST /api/v1/admin/sync-git (admin) — fetch upstream git mirrors now The mirror admin endpoints live on the mirror router because they're mirror-specific. + +``sync-git`` accepts an ``allow_runtime_sync`` query flag. When +``git.runtime_sync`` is false (air-gap), the endpoint refuses to reach +upstream unless this flag is explicitly true — that prevents an +operator from accidentally bypassing the air-gap with a stray click. """ + from __future__ import annotations -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, Query, status from app.config import reload_config from app.security import require_admin from app.services.catalog_poll import run_catalog_poll +from app.services.git_mirror import run_git_sync router = APIRouter(prefix="/api/v1/admin", tags=["admin"]) @@ -40,3 +48,21 @@ def admin_reload_config() -> dict: ) def admin_sync_catalog() -> dict: return run_catalog_poll() + + +@router.post( + "/sync-git", + status_code=status.HTTP_200_OK, + dependencies=[Depends(require_admin)], +) +def admin_sync_git( + allow_runtime_sync: bool = Query( + False, + description=( + "Required to reach upstream when git.runtime_sync is false. " + "Defaults to false so air-gap deployments don't accidentally " + "egress to github.com on a stray admin click." + ), + ), +) -> dict: + return run_git_sync(force=True, allow_runtime_sync=allow_runtime_sync) diff --git a/cc-catalog-svc/app/routers/catalog.py b/cc-catalog-svc/app/routers/catalog.py index 6141affedc7..42f9fbe7b1f 100644 --- a/cc-catalog-svc/app/routers/catalog.py +++ b/cc-catalog-svc/app/routers/catalog.py @@ -18,6 +18,7 @@ All endpoints are read-only and unauthenticated by design. """ + from __future__ import annotations from typing import Optional @@ -41,6 +42,7 @@ find_ref_by_name, get_cc_by_slug, ) +from app.services.git_mirror import resolve_git_url_for_catalog router = APIRouter(prefix="/api/v1/catalog", tags=["catalog"]) @@ -96,7 +98,7 @@ def list_catalog( CatalogEntry( slug=cc.slug, name=cc.name, - git_url=cc.git_url, + git_url=resolve_git_url_for_catalog(cc), visibility=cc.visibility or "public", latest_image_tag=latest_tag, stable_image_tag=stable_tag, @@ -117,7 +119,7 @@ def get_catalog_entry(slug: str, db: Session = Depends(db_session)) -> CatalogEn return CatalogEntryDetail( slug=cc.slug, name=cc.name, - git_url=cc.git_url, + git_url=resolve_git_url_for_catalog(cc), visibility=cc.visibility or "public", latest_image_tag=latest_tag, stable_image_tag=stable_tag, @@ -150,9 +152,7 @@ def get_ref(slug: str, ref: str, db: Session = Depends(db_session)) -> ImageRefS raise HTTPException(status_code=404, detail=f"unknown codecollection: {slug}") row = find_ref_by_name(db, cc.id, ref) if row is None or not row.image_tag: - raise HTTPException( - status_code=404, detail=f"no image for {slug}@{ref}" - ) + raise HTTPException(status_code=404, detail=f"no image for {slug}@{ref}") return _to_image_ref(row) @@ -160,12 +160,11 @@ def get_ref(slug: str, ref: str, db: Session = Depends(db_session)) -> ImageRefS def resolve_image( slug: str, pointer: Optional[str] = Query( - None, pattern="^(latest|stable)$", + None, + pattern="^(latest|stable)$", description="Resolve a named pointer ('latest' or 'stable').", ), - ref: Optional[str] = Query( - None, description="Resolve a specific git ref name (branch/tag)." - ), + ref: Optional[str] = Query(None, description="Resolve a specific git ref name (branch/tag)."), destination: Optional[str] = Query( None, description=( @@ -242,9 +241,7 @@ def _attach_destination( """ dest_cfg = get_config().destination_by_name(dest_name) if dest_cfg is None: - raise HTTPException( - status_code=404, detail=f"unknown destination: {dest_name}" - ) + raise HTTPException(status_code=404, detail=f"unknown destination: {dest_name}") dest_row = db.execute( select(Destination).where(Destination.name == dest_name) diff --git a/cc-catalog-svc/app/routers/git.py b/cc-catalog-svc/app/routers/git.py new file mode 100644 index 00000000000..de2971f9fec --- /dev/null +++ b/cc-catalog-svc/app/routers/git.py @@ -0,0 +1,50 @@ +"""Git mirror status API (read-only).""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from app.config import get_config +from app.services.git_mirror import list_repo_status + +router = APIRouter(prefix="/api/v1/git", tags=["git"]) + + +def _require_git_enabled() -> None: + if not get_config().git.enabled: + raise HTTPException(status_code=404, detail="git mirror service is disabled") + + +@router.get("/repos") +def list_git_repos() -> list[dict]: + _require_git_enabled() + return [ + { + "slug": s.slug, + "upstream_url": s.upstream_url, + "public_url": s.public_url, + "present": s.present, + "head_commit": s.head_commit, + "last_synced": s.last_synced, + "last_sync_error": s.last_sync_error, + } + for s in list_repo_status() + ] + + +@router.get("/repos/{slug}") +def get_git_repo(slug: str) -> dict: + _require_git_enabled() + for s in list_repo_status(): + if s.slug == slug: + return { + "slug": s.slug, + "upstream_url": s.upstream_url, + "public_url": s.public_url, + "local_path": s.local_path, + "present": s.present, + "head_commit": s.head_commit, + "last_synced": s.last_synced, + "last_sync_error": s.last_sync_error, + } + raise HTTPException(status_code=404, detail=f"unknown git repo: {slug}") diff --git a/cc-catalog-svc/app/scheduler.py b/cc-catalog-svc/app/scheduler.py index 6d3c21852ee..7d7b48f1697 100644 --- a/cc-catalog-svc/app/scheduler.py +++ b/cc-catalog-svc/app/scheduler.py @@ -17,6 +17,7 @@ The scheduler is wired in via the FastAPI lifespan in `app/main.py`. """ + from __future__ import annotations import logging @@ -27,6 +28,7 @@ from app.config import get_config from app.services.catalog_poll import run_catalog_poll +from app.services.git_mirror import run_git_sync from app.services.mirror import ( drain_mirror_jobs, enqueue_mirror_jobs, @@ -96,13 +98,26 @@ def start_scheduler() -> BackgroundScheduler: next_run_time=_now_plus(seconds=45), ) + if cfg.git.enabled and cfg.git.runtime_sync: + bg.add_job( + run_git_sync, + trigger="interval", + minutes=sched.git_sync_minutes, + id="git-sync", + name="git-sync", + next_run_time=_now_plus(seconds=60), + ) + bg.start() _scheduler = bg logger.info( - "scheduler started: catalog_poll=%dm mirror_poll=%dm workers=%d", + "scheduler started: catalog_poll=%dm mirror_poll=%dm workers=%d " + "git_sync=%dm git_runtime_sync=%s", sched.catalog_poll_minutes, sched.mirror_poll_minutes, sched.mirror_workers, + sched.git_sync_minutes, + cfg.git.runtime_sync if cfg.git.enabled else "n/a", ) return bg @@ -120,4 +135,5 @@ def _now_plus(*, seconds: int): """A small initial delay so we don't run jobs before the HTTP listener is up — keeps the first /readyz probe honest.""" from datetime import datetime, timedelta, timezone + return datetime.now(timezone.utc) + timedelta(seconds=seconds) diff --git a/cc-catalog-svc/app/services/catalog_poll.py b/cc-catalog-svc/app/services/catalog_poll.py index 862ddf8c54d..fd310cf9948 100644 --- a/cc-catalog-svc/app/services/catalog_poll.py +++ b/cc-catalog-svc/app/services/catalog_poll.py @@ -38,6 +38,22 @@ def _utcnow() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) +def _ensure_aware(ts: Optional[datetime]) -> Optional[datetime]: + """Compare-safe datetime: stamp naive values as UTC. + + Sources return tz-aware datetimes (HTTP Last-Modified is RFC 7231, + OCI manifests use RFC 3339), but historical DB rows may be naive. + Mixing the two in a comparison raises TypeError, which would break + the tiebreak. Treating naive as UTC is consistent with how + ``_utcnow`` writes timestamps elsewhere in this module. + """ + if ts is None: + return None + if ts.tzinfo is None: + return ts.replace(tzinfo=timezone.utc) + return ts + + def run_catalog_poll(config: Optional[AppConfig] = None) -> dict: """Discover refs for every configured CC; upsert into the DB. @@ -167,14 +183,31 @@ def _upsert_refs( now = _utcnow() # Group by ref name. Multiple discovered tags can share a single git # ref (e.g. several builds of `main`). The row schema keys on ref_name, - # so we have to pick one. Choose the lexicographically-largest - # image_tag so the kept entry matches what OCISource.resolve_latest - # selects (same ordering); otherwise is_latest never matches the row - # that wins the dict and /resolve?pointer=latest 404s. + # so we have to pick one. Match OCISource.resolve_latest's ordering + # exactly: prefer the highest ``built_at`` (manifest creation time as + # populated by the source's tiebreak enrichment), falling back to a + # lex sort on ``image_tag`` when timestamps are missing or tied. + # + # Earlier versions used the lex sort alone — that's wrong for our + # canonical ``--`` schema because cc_sha7 is hex + # and has no temporal ordering (``main-1...`` sorts before ``main-d...`` + # even if the ``1...`` build is newer). With ``built_at`` populated the + # surviving row tracks what ``resolve_latest`` declared as the latest + # tag, so ``is_latest=True`` ends up on the right row and the + # ``/resolve?pointer=latest`` endpoint returns the freshest build. + _EPOCH_MIN = datetime.min.replace(tzinfo=timezone.utc) + + def _newer(a, b) -> bool: + a_ts = _ensure_aware(getattr(a, "built_at", None)) or _EPOCH_MIN + b_ts = _ensure_aware(getattr(b, "built_at", None)) or _EPOCH_MIN + if a_ts != b_ts: + return a_ts > b_ts + return (a.image_tag or "") > (b.image_tag or "") + refs_by_name: dict[str, object] = {} for r in refs: existing_choice = refs_by_name.get(r.ref) - if existing_choice is None or (r.image_tag or "") > (existing_choice.image_tag or ""): + if existing_choice is None or _newer(r, existing_choice): refs_by_name[r.ref] = r existing = db.execute(select(ImageRef).where(ImageRef.cc_id == cc_row.id)).scalars().all() diff --git a/cc-catalog-svc/app/services/git_mirror.py b/cc-catalog-svc/app/services/git_mirror.py new file mode 100644 index 00000000000..378ed81158d --- /dev/null +++ b/cc-catalog-svc/app/services/git_mirror.py @@ -0,0 +1,434 @@ +""" +Git repository mirror sync. + +When ``git.enabled`` is set in config.yaml, this service maintains bare +mirror clones of each configured CodeCollection's ``git_url`` under +``git.data_dir``. The mirrors are served read-only via git smart HTTP +(see ``app/git_http``). + +Upstream fetch uses the optional ``git.auth`` block (PAT via +``token_env`` or HTTP Basic) so private GitHub repos can be mirrored +during the brief window when outbound access is available. + +Air-gap mode: when ``runtime_sync`` is false the scheduler never runs +sync, and ``run_git_sync(force=True)`` (admin endpoint) refuses by +default. Operators that need to refresh mirrors from an air-gapped +deployment must pass ``allow_runtime_sync=True`` *and* flip +``git.runtime_sync`` to true via config reload — this is intentional, so +nobody accidentally reaches public github.com from an air-gapped +cluster. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import threading +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select + +from app.config import AppConfig, GitAuth, GitServiceConfig, get_config +from app.db import session_scope +from app.git_http import ( + is_valid_slug, + list_bare_repo_slugs, + repo_bare_path, + repo_exists, +) +from app.models import CodeCollection + +logger = logging.getLogger(__name__) + +# Process-wide lock guarding both scheduled and admin-triggered runs. +# Prevents two ``git clone --mirror`` / ``remote update`` invocations +# from hitting the same on-disk bare repo concurrently and corrupting +# packs / refs. +_SYNC_LOCK = threading.Lock() + + +@dataclass +class GitRepoStatus: + slug: str + upstream_url: str + local_path: str + public_url: Optional[str] + head_commit: Optional[str] + last_synced: Optional[datetime] + last_sync_error: Optional[str] + present: bool + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def public_git_url(slug: str, git_cfg: GitServiceConfig) -> Optional[str]: + if not git_cfg.enabled or not git_cfg.public_base_url: + return None + if not is_valid_slug(slug): + return None + return f"{git_cfg.public_base_url.rstrip('/')}/{slug}.git" + + +def resolve_git_url_for_catalog( + cc: CodeCollection, + cfg: Optional[AppConfig] = None, +) -> Optional[str]: + """Return the git URL platform consumers should clone from. + + When git mirroring is enabled and the local bare repo exists, return + the configured ``public_base_url`` clone URL. Otherwise fall back to + the upstream URL stored on the CC row. + """ + app_cfg = cfg or get_config() + git_cfg = app_cfg.git + if git_cfg.enabled and git_cfg.public_base_url and repo_exists(git_cfg.data_dir, cc.slug): + return public_git_url(cc.slug, git_cfg) + return cc.git_url + + +def repos_to_sync(cfg: AppConfig) -> list[tuple[str, str]]: + """Return ``(slug, upstream_url)`` pairs to mirror.""" + git_cfg = cfg.git + if not git_cfg.enabled: + return [] + + if git_cfg.codecollections: + pairs: list[tuple[str, str]] = [] + all_cc = cfg.all_codecollections() + for slug in git_cfg.codecollections: + cc = all_cc.get(slug) + if cc is None: + logger.warning("git mirror: unknown slug %r in git.codecollections", slug) + continue + if not cc.git_url: + logger.warning("git mirror: slug %r has no git_url", slug) + continue + pairs.append((slug, cc.git_url)) + return pairs + + return [(slug, cc.git_url) for slug, cc in cfg.all_codecollections().items() if cc.git_url] + + +def _head_commit_from_disk(bare_path: str) -> Optional[str]: + """Return the HEAD commit of a bare repo on disk, or None on error.""" + if not os.path.isdir(bare_path): + return None + try: + proc = subprocess.run( + ["git", "--git-dir", bare_path, "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + except (subprocess.SubprocessError, FileNotFoundError): + return None + head = proc.stdout.strip() + return head or None + + +def list_repo_status(cfg: Optional[AppConfig] = None) -> list[GitRepoStatus]: + app_cfg = cfg or get_config() + git_cfg = app_cfg.git + statuses: list[GitRepoStatus] = [] + + with session_scope() as db: + rows = {r.slug: r for r in db.execute(select(CodeCollection)).scalars().all()} + + for slug, upstream in repos_to_sync(app_cfg): + row = rows.get(slug) + local_path = repo_bare_path(git_cfg.data_dir, slug) + present = repo_exists(git_cfg.data_dir, slug) + # Prefer the DB-tracked head_commit (set on the most recent + # successful runtime sync), but fall back to reading HEAD off + # disk so build-time baked mirrors still surface a commit even + # when runtime_sync=False. + head_commit = getattr(row, "git_head_commit", None) if row else None + if head_commit is None and present: + head_commit = _head_commit_from_disk(local_path) + statuses.append( + GitRepoStatus( + slug=slug, + upstream_url=upstream, + local_path=local_path, + public_url=public_git_url(slug, git_cfg), + head_commit=head_commit, + last_synced=getattr(row, "git_last_synced", None) if row else None, + last_sync_error=getattr(row, "git_last_sync_error", None) if row else None, + present=present, + ) + ) + return statuses + + +def run_git_sync( + config: Optional[AppConfig] = None, + *, + force: bool = False, + allow_runtime_sync: bool = False, +) -> dict: + """Fetch/update every configured git mirror. + + Args: + force: bypass the "is anything stale" heuristic and sync now. + Useful for the admin endpoint. Does NOT bypass + ``runtime_sync=False`` — see ``allow_runtime_sync``. + allow_runtime_sync: required for sync to actually reach upstream + when ``git.runtime_sync`` is false. Defaults to False so an + air-gapped operator who calls the admin endpoint by mistake + doesn't trigger outbound git traffic. + + Concurrency: holds a process-wide lock for the whole run, so an + admin POST that arrives mid-scheduler-tick waits rather than + racing the scheduler on the same bare repos. + """ + cfg = config or get_config() + git_cfg = cfg.git + summary: dict = { + "enabled": git_cfg.enabled, + "runtime_sync": git_cfg.runtime_sync, + "repos_processed": 0, + "repos_updated": 0, + "errors": [], + } + if not git_cfg.enabled: + return summary + if not git_cfg.runtime_sync and not allow_runtime_sync: + summary["skipped"] = ( + "runtime_sync disabled (using build-time baked mirrors); " + "pass allow_runtime_sync=True to override" + ) + return summary + # ``force`` is retained for callers that want to bypass a future + # "is anything stale" check. We don't have one yet, so it's a no-op + # other than nudging operators that they explicitly asked for it. + _ = force + + acquired = _SYNC_LOCK.acquire(blocking=False) + if not acquired: + summary["skipped"] = "another git sync is already running" + return summary + + try: + os.makedirs(git_cfg.data_dir, exist_ok=True) + for slug, upstream_url in repos_to_sync(cfg): + summary["repos_processed"] += 1 + try: + head = sync_one_repo(slug, upstream_url, git_cfg) + _record_git_sync_success(slug, head) + summary["repos_updated"] += 1 + except Exception as exc: + logger.exception("git mirror sync failed for %s", slug) + summary["errors"].append({"slug": slug, "error": str(exc)}) + _record_git_sync_error(slug, str(exc)) + logger.info("git mirror sync complete: %s", summary) + finally: + _SYNC_LOCK.release() + return summary + + +def _is_complete_bare_clone(path: str) -> bool: + """A bare clone is 'complete' if HEAD exists and has refs.""" + if not os.path.isfile(os.path.join(path, "HEAD")): + return False + objects = os.path.join(path, "objects") + if not os.path.isdir(objects): + return False + return True + + +def _ensure_remote_url(dest: str, upstream_url: str) -> None: + """Make sure ``origin`` points at ``upstream_url``; reset if not.""" + try: + proc = subprocess.run( + ["git", "--git-dir", dest, "remote", "get-url", "origin"], + check=False, + capture_output=True, + text=True, + timeout=10, + ) + except (subprocess.SubprocessError, FileNotFoundError): + return + current = proc.stdout.strip() + if proc.returncode == 0 and current == upstream_url: + return + logger.info( + "git mirror: updating origin url for %s (was %r, now %r)", + dest, + current or "", + upstream_url, + ) + subprocess.run( + ["git", "--git-dir", dest, "remote", "set-url", "origin", upstream_url], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + + +def sync_one_repo(slug: str, upstream_url: str, git_cfg: GitServiceConfig) -> str: + """Clone or update one bare mirror. Returns HEAD commit hash.""" + dest = repo_bare_path(git_cfg.data_dir, slug) + os.makedirs(git_cfg.data_dir, exist_ok=True) + + # If a previous clone died mid-flight the directory exists but is + # missing HEAD / objects. Treat as garbage and re-clone, otherwise + # ``git remote update`` would just keep failing forever. + if os.path.isdir(dest) and not _is_complete_bare_clone(dest): + logger.warning( + "git mirror: %s exists but is not a complete bare clone; removing and re-cloning", + dest, + ) + shutil.rmtree(dest, ignore_errors=True) + + if not os.path.isdir(dest): + _run_git( + ["clone", "--mirror", upstream_url, dest], + git_cfg.auth, + timeout=git_cfg.clone_timeout_seconds, + ) + else: + # If the configured upstream_url changed since the last clone + # (e.g. operator updated config.yaml to point at a fork) reset + # origin so subsequent fetches pull from the right place. + _ensure_remote_url(dest, upstream_url) + _run_git( + ["-C", dest, "remote", "update", "--prune"], + git_cfg.auth, + timeout=git_cfg.fetch_timeout_seconds, + ) + + head = _run_git( + ["--git-dir", dest, "rev-parse", "HEAD"], + git_cfg.auth, + timeout=30, + ).stdout.strip() + if not head: + raise RuntimeError(f"mirror at {dest} has no HEAD after sync") + return head + + +def populate_baked_head_commits(cfg: Optional[AppConfig] = None) -> int: + """Backfill ``CodeCollection.git_head_commit`` from baked mirrors on disk. + + Called once at startup so ``GET /api/v1/git/repos`` and catalog + rewrites have accurate HEAD info immediately, even in air-gap + deployments where ``run_git_sync`` is never invoked. + + Returns the number of rows touched. + """ + app_cfg = cfg or get_config() + git_cfg = app_cfg.git + if not git_cfg.enabled: + return 0 + + on_disk = set(list_bare_repo_slugs(git_cfg.data_dir)) + if not on_disk: + return 0 + + touched = 0 + with session_scope() as db: + rows_by_slug = { + r.slug: r for r in db.execute(select(CodeCollection)).scalars().all() + } + for slug, _ in repos_to_sync(app_cfg): + if slug not in on_disk: + continue + head = _head_commit_from_disk(repo_bare_path(git_cfg.data_dir, slug)) + if not head: + continue + row = rows_by_slug.get(slug) + if row is None: + row = CodeCollection(slug=slug) + db.add(row) + if row.git_head_commit == head: + continue + row.git_head_commit = head + # Don't fake a last_synced timestamp — baked content was + # synced at build time, not now. ``last_synced`` stays NULL + # until a real runtime sync fires. + touched += 1 + if touched: + logger.info("git mirror: backfilled head_commit for %d baked repo(s)", touched) + return touched + + +def _git_auth_args(auth: GitAuth) -> list[str]: + args: list[str] = [] + token = os.environ.get(auth.token_env, "") if auth.token_env else "" + if token: + # GitHub git HTTPS expects Basic x-access-token, not Bearer (API-only). + args.extend( + [ + "-c", + f"http.extraHeader=Authorization: Basic {_basic_auth('x-access-token', token)}", + ] + ) + return args + + user = os.environ.get(auth.user_env, "") if auth.user_env else "" + password = os.environ.get(auth.pass_env, "") if auth.pass_env else "" + if user and password: + args.extend(["-c", f"http.extraHeader=Authorization: Basic {_basic_auth(user, password)}"]) + return args + + +def _basic_auth(user: str, password: str) -> str: + import base64 + + raw = f"{user}:{password}".encode() + return base64.b64encode(raw).decode("ascii") + + +def _run_git( + args: list[str], + auth: GitAuth, + *, + timeout: int, +) -> subprocess.CompletedProcess[str]: + cmd = ["git", *_git_auth_args(auth), *args] + # Never log the full command — auth args may embed secrets indirectly. + logger.debug("running git %s", " ".join(args)) + proc = subprocess.run( + cmd, + check=True, + capture_output=True, + text=True, + timeout=timeout, + env={ + **os.environ, + "GIT_TERMINAL_PROMPT": "0", + }, + ) + return proc + + +def _record_git_sync_success(slug: str, head_commit: str) -> None: + with session_scope() as db: + row = db.execute( + select(CodeCollection).where(CodeCollection.slug == slug) + ).scalar_one_or_none() + if row is None: + row = CodeCollection(slug=slug) + db.add(row) + row.git_head_commit = head_commit + row.git_last_synced = _utcnow() + row.git_last_sync_error = None + + +def _record_git_sync_error(slug: str, error: str) -> None: + with session_scope() as db: + row = db.execute( + select(CodeCollection).where(CodeCollection.slug == slug) + ).scalar_one_or_none() + if row is None: + row = CodeCollection(slug=slug) + db.add(row) + row.git_last_sync_error = error[:4000] diff --git a/cc-catalog-svc/app/sources/oci.py b/cc-catalog-svc/app/sources/oci.py index 835288de259..2cefe01010b 100644 --- a/cc-catalog-svc/app/sources/oci.py +++ b/cc-catalog-svc/app/sources/oci.py @@ -26,6 +26,7 @@ from __future__ import annotations import base64 +import dataclasses import logging import os import re @@ -42,6 +43,19 @@ TAG_PATTERN = re.compile(r"^(?P.+?)-(?P[0-9a-f]{7,40})-(?P[0-9a-f]{7,40})$") SEMVER_TAG = re.compile(r"^v?\d+\.\d+(\.\d+)?") +# Accept headers we send when fetching an OCI manifest. Order matters: the +# registry returns the first content-type it supports, so we list OCI types +# before Docker ones. Without an explicit Accept the registry MAY return a +# legacy v1 manifest, which has no `config.digest` field we can follow. +_MANIFEST_ACCEPT = ",".join( + [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.docker.distribution.manifest.v2+json", + ] +) + class OCISource(ImageSource): name = "oci" @@ -66,14 +80,33 @@ def discover_refs(self, cc: dict) -> list[DiscoveredImageRef]: host, repo = self._split_registry_url(registry_url) auth_header, auth_mode = self._resolve_auth_header(cc) - tags = self._list_tags(host, repo, auth_header=auth_header, auth_mode=auth_mode) - - discovered: list[DiscoveredImageRef] = [] - for tag in tags: - parsed = self._parse_tag(tag) - if parsed is None: - continue - discovered.append(parsed) + + # Single httpx.Client so the bearer dance, manifest GETs, and + # config-blob fetches reuse the same TCP connection / token. + with httpx.Client(timeout=self.timeout, follow_redirects=True) as client: + tags = self._list_tags( + client, host, repo, auth_header=auth_header, auth_mode=auth_mode + ) + + discovered: list[DiscoveredImageRef] = [] + for tag in tags: + parsed = self._parse_tag(tag) + if parsed is None: + continue + discovered.append(parsed) + + # When multiple canonical tags share a ref (e.g. `main--` + # AND `main--`) we MUST pick the newer one. Lexicographic + # sort on `image_tag` is wrong: the cc_sha7 prefix is hex, so + # `main-1...` sorts before `main-d...` even when the `1...` push + # happened a week later. Fetch the real build timestamp from the + # registry so the existing (built_at, image_tag) sort produces a + # correct order. We only do this for tags that actually compete + # — most polls touch zero extra endpoints. + discovered = self._enrich_built_at_for_tiebreaks( + client, host, repo, discovered, auth_header, auth_mode + ) + logger.info( "oci source: %s -> %d tags, %d matched build schema", cc.get("slug"), @@ -164,6 +197,7 @@ def _resolve_auth_header(cc: dict) -> tuple[Optional[str], str]: def _list_tags( self, + client: httpx.Client, host: str, repo: str, auth_header: Optional[str] = None, @@ -173,25 +207,24 @@ def _list_tags( url = f"https://{host}/v2/{repo}/tags/list" params: dict = {"n": 200} all_tags: list[str] = [] - with httpx.Client(timeout=self.timeout, follow_redirects=True) as client: - for _ in range(self.max_pages): - resp = self._get_with_auth( - client, - host, - repo, - url, - params, - auth_header=auth_header, - auth_mode=auth_mode, - ) - resp.raise_for_status() - payload = resp.json() - all_tags.extend(payload.get("tags") or []) - link = resp.headers.get("Link") or "" - next_url = self._parse_next_link(link, host) - if not next_url: - break - url, params = next_url, {} + for _ in range(self.max_pages): + resp = self._get_with_auth( + client, + host, + repo, + url, + params, + auth_header=auth_header, + auth_mode=auth_mode, + ) + resp.raise_for_status() + payload = resp.json() + all_tags.extend(payload.get("tags") or []) + link = resp.headers.get("Link") or "" + next_url = self._parse_next_link(link, host) + if not next_url: + break + url, params = next_url, {} return all_tags def _get_with_auth( @@ -203,6 +236,7 @@ def _get_with_auth( params: dict, auth_header: Optional[str] = None, auth_mode: str = "anonymous", + accept: Optional[str] = None, ) -> httpx.Response: """Send the request with whatever auth we have; on 401 with a Bearer-realm WWW-Authenticate header, do the realm-token dance @@ -229,7 +263,11 @@ def _get_with_auth( `/artifactory/api/docker//v2/...` does take Basic directly, but we use the canonical OCI path so we have to dance. """ - headers = {"Authorization": auth_header} if auth_header else {} + headers: dict[str, str] = {} + if auth_header: + headers["Authorization"] = auth_header + if accept: + headers["Accept"] = accept resp = client.get(url, params=params, headers=headers) if resp.status_code != 401: return resp @@ -259,11 +297,194 @@ def _get_with_auth( token = token_resp.json().get("token") or token_resp.json().get("access_token") if not token: return resp - return client.get( - url, - params=params, - headers={"Authorization": f"Bearer {token}"}, + retry_headers = {"Authorization": f"Bearer {token}"} + if accept: + retry_headers["Accept"] = accept + return client.get(url, params=params, headers=retry_headers) + + # ------------------------------------------------------------------ + # tiebreak enrichment + # ------------------------------------------------------------------ + def _enrich_built_at_for_tiebreaks( + self, + client: httpx.Client, + host: str, + repo: str, + refs: list[DiscoveredImageRef], + auth_header: Optional[str], + auth_mode: str, + ) -> list[DiscoveredImageRef]: + """Set ``built_at`` on refs whose ``ref`` is shared by >1 image_tag. + + We only fetch manifests for the ambiguous subset because: + - each enriched tag is up to two registry round-trips, and + - when a ref has exactly one tag, there's nothing to tiebreak. + + Failures are best-effort: a single broken tag must not poison the + whole poll, so any exception just leaves built_at=None and the + downstream sort falls back to lex-on-image_tag. + """ + by_ref: dict[str, list[DiscoveredImageRef]] = {} + for r in refs: + by_ref.setdefault(r.ref, []).append(r) + ambiguous_tags = { + r.image_tag + for group in by_ref.values() + if len(group) > 1 + for r in group + } + if not ambiguous_tags: + return refs + + built_at_by_tag: dict[str, datetime] = {} + for tag in ambiguous_tags: + built_at = self._fetch_built_at_for_tag( + client, host, repo, tag, auth_header, auth_mode + ) + if built_at is not None: + built_at_by_tag[tag] = built_at + + if not built_at_by_tag: + return refs + + return [ + dataclasses.replace(r, built_at=built_at_by_tag[r.image_tag]) + if r.image_tag in built_at_by_tag + else r + for r in refs + ] + + def _fetch_built_at_for_tag( + self, + client: httpx.Client, + host: str, + repo: str, + tag: str, + auth_header: Optional[str], + auth_mode: str, + ) -> Optional[datetime]: + """Return the build timestamp of an OCI image, best-effort. + + Strategy: GET ``/v2//manifests/`` with Accept headers, + descend into the manifest's ``config.digest`` blob (or, for OCI + image indices, the first child platform manifest's config blob), + and read its ``created`` field. That field is always populated by + buildkit / docker buildx and is the only OCI-spec'd source of + truth for when the image was actually built. + + We deliberately do NOT use the ``Last-Modified`` HTTP header, + even though many registries set it. Caching proxies — most + notably JFrog Artifactory's docker-remote setup which is the + whole reason this code exists — set ``Last-Modified`` to the + local CACHE freshness time, not the upstream build time. That + means whichever tag the poll happens to GET first or last would + win the tiebreak based on cache-warmup order, completely + unrelated to which image is actually newer. The OCI spec does + not require ``Last-Modified`` either, so taking the extra HTTP + hop to ``config.digest`` is the only universally correct path. + + Returns None on any failure so the caller can fall back to its + lex-only ordering rather than crash the poll. + """ + manifest_url = f"https://{host}/v2/{repo}/manifests/{tag}" + try: + resp = self._get_with_auth( + client, + host, + repo, + manifest_url, + params={}, + auth_header=auth_header, + auth_mode=auth_mode, + accept=_MANIFEST_ACCEPT, + ) + if resp.status_code != 200: + logger.debug( + "oci source: manifest GET %s:%s returned %s", + repo, + tag, + resp.status_code, + ) + return None + + manifest = resp.json() + return self._fetch_created_from_manifest( + client, host, repo, manifest, auth_header, auth_mode + ) + except Exception: + logger.debug( + "oci source: failed to fetch built_at for %s:%s", + repo, + tag, + exc_info=True, + ) + return None + + def _fetch_created_from_manifest( + self, + client: httpx.Client, + host: str, + repo: str, + manifest: dict, + auth_header: Optional[str], + auth_mode: str, + ) -> Optional[datetime]: + """Resolve a manifest doc to its config-blob ``created`` timestamp. + + Handles both single-platform manifests (``config.digest`` is on + the top-level) and image indices / manifest lists (we descend into + the first child manifest, which is the conventional approach since + all platforms of a multi-arch build share the same buildkit + timestamp anyway). + """ + config_digest: Optional[str] = None + child_manifests = manifest.get("manifests") + if child_manifests: + child_digest = (child_manifests[0] or {}).get("digest") + if not child_digest: + return None + child_url = f"https://{host}/v2/{repo}/manifests/{child_digest}" + child_resp = self._get_with_auth( + client, + host, + repo, + child_url, + params={}, + auth_header=auth_header, + auth_mode=auth_mode, + accept=_MANIFEST_ACCEPT, + ) + if child_resp.status_code != 200: + return None + config_digest = (child_resp.json().get("config") or {}).get("digest") + else: + config_digest = (manifest.get("config") or {}).get("digest") + + if not config_digest: + return None + + blob_url = f"https://{host}/v2/{repo}/blobs/{config_digest}" + blob_resp = self._get_with_auth( + client, + host, + repo, + blob_url, + params={}, + auth_header=auth_header, + auth_mode=auth_mode, ) + if blob_resp.status_code != 200: + return None + created = blob_resp.json().get("created") + if not created: + return None + # OCI uses RFC 3339; normalize "Z" suffix for fromisoformat (<3.11). + if created.endswith("Z"): + created = created[:-1] + "+00:00" + try: + return datetime.fromisoformat(created) + except ValueError: + return None @staticmethod def _parse_next_link(link_header: str, host: str) -> Optional[str]: diff --git a/cc-catalog-svc/config-examples/config.airgap.yaml b/cc-catalog-svc/config-examples/config.airgap.yaml new file mode 100644 index 00000000000..d42d526f9f7 --- /dev/null +++ b/cc-catalog-svc/config-examples/config.airgap.yaml @@ -0,0 +1,69 @@ +# Air-gapped deployment — git repos baked into the image at build time. +# +# Mirrors live at /opt/cc-catalog/git (NOT under /data — a PVC on /data would +# hide baked content). runtime_sync is off so the scheduler never tries to +# reach github.com after deploy. +# +# data_dir defaults to /opt/cc-catalog/git already (matches the Dockerfile +# bake stage). It's listed explicitly here for documentation. +# +# Admin sync: POST /api/v1/admin/sync-git is a no-op while runtime_sync=false. +# An operator who really needs to refresh from upstream must call: +# POST /api/v1/admin/sync-git?allow_runtime_sync=true +# This guard rail exists so a stray click can't egress to github.com from an +# air-gapped cluster. +# +# Mount your environment-specific values (public_base_url, JFrog creds) via +# ConfigMap/Secret. This file is a starting template. +# +# See docs/GIT_MIRROR.md for the full operator runbook. + +catalog_api: + prefix: /api/v1/catalog + +scheduler: + catalog_poll_minutes: 15 + mirror_poll_minutes: 15 + git_sync_minutes: 60 # ignored when git.runtime_sync is false + +git: + enabled: true + data_dir: /opt/cc-catalog/git + mount_path: /git + public_base_url: https://cc-catalog.example.com/git + runtime_sync: false + +sources: + # Pattern C: poll JFrog Remote Docker repo (no ghcr.io egress at runtime). + - name: jfrog-remote-runwhen + type: oci + auth: + user_env: JFROG_USER + pass_env: JFROG_PASS + codecollections: + - slug: rw-cli-codecollection + name: RunWhen CLI CodeCollection + git_url: https://github.com/runwhen-contrib/rw-cli-codecollection + image_registry: artifactory.example.com/docker-ghcr/runwhen-contrib/rw-cli-codecollection + - slug: rw-public-codecollection + name: RunWhen Public CodeCollection + git_url: https://github.com/runwhen-contrib/rw-public-codecollection + image_registry: artifactory.example.com/docker-ghcr/runwhen-contrib/rw-public-codecollection + - slug: rw-generic-codecollection + name: RunWhen Generic CodeCollection + git_url: https://github.com/runwhen-contrib/rw-generic-codecollection + image_registry: artifactory.example.com/docker-ghcr/runwhen-contrib/rw-generic-codecollection + - slug: rw-workspace-utils + name: RunWhen Workspace Utilities CodeCollection + git_url: https://github.com/runwhen-contrib/rw-workspace-utils + image_registry: artifactory.example.com/docker-ghcr/runwhen-contrib/rw-workspace-utils + - slug: aws-c7n-codecollection + name: AWS CloudCustodian CodeCollection + git_url: https://github.com/runwhen-contrib/aws-c7n-codecollection + image_registry: artifactory.example.com/docker-ghcr/runwhen-contrib/aws-c7n-codecollection + - slug: azure-c7n-codecollection + name: Azure CloudCustodian CodeCollection + git_url: https://github.com/runwhen-contrib/azure-c7n-codecollection + image_registry: artifactory.example.com/docker-ghcr/runwhen-contrib/azure-c7n-codecollection + +# No destinations — JFrog Remote handles image caching on first pull. diff --git a/cc-catalog-svc/config-examples/config.bake.yaml b/cc-catalog-svc/config-examples/config.bake.yaml new file mode 100644 index 00000000000..fec383272f4 --- /dev/null +++ b/cc-catalog-svc/config-examples/config.bake.yaml @@ -0,0 +1,38 @@ +# Docker build manifest — bare git mirrors baked into the release image. +# +# Used only by scripts/bake_git_mirrors.py during `docker build`. The running +# container reads config.yaml from the deployment ConfigMap instead. +# +# Keep this in sync with the CodeCollections you ship in air-gap releases. + +git: + enabled: true + auth: + # GITHUB_TOKEN is injected as a BuildKit secret in CI (rate limits + private repos). + token_env: GITHUB_TOKEN + +sources: + - name: bake + type: oci + codecollections: + - slug: rw-cli-codecollection + git_url: https://github.com/runwhen-contrib/rw-cli-codecollection + image_registry: ghcr.io/runwhen-contrib/rw-cli-codecollection + - slug: rw-public-codecollection + git_url: https://github.com/runwhen-contrib/rw-public-codecollection + image_registry: ghcr.io/runwhen-contrib/rw-public-codecollection + - slug: rw-generic-codecollection + git_url: https://github.com/runwhen-contrib/rw-generic-codecollection + image_registry: ghcr.io/runwhen-contrib/rw-generic-codecollection + - slug: rw-workspace-utils + git_url: https://github.com/runwhen-contrib/rw-workspace-utils + image_registry: ghcr.io/runwhen-contrib/rw-workspace-utils + - slug: aws-c7n-codecollection + git_url: https://github.com/runwhen-contrib/aws-c7n-codecollection + image_registry: ghcr.io/runwhen-contrib/aws-c7n-codecollection + - slug: azure-c7n-codecollection + git_url: https://github.com/runwhen-contrib/azure-c7n-codecollection + image_registry: ghcr.io/runwhen-contrib/azure-c7n-codecollection + - slug: ss-rw-cli-codecollection + git_url: https://github.com/stewartshea/rw-cli-codecollection + image_registry: ghcr.io/stewartshea/rw-cli-codecollection \ No newline at end of file diff --git a/cc-catalog-svc/config-examples/config.example.yaml b/cc-catalog-svc/config-examples/config.example.yaml index e6304f0c633..f32a0b08f04 100644 --- a/cc-catalog-svc/config-examples/config.example.yaml +++ b/cc-catalog-svc/config-examples/config.example.yaml @@ -21,6 +21,29 @@ scheduler: mirror_poll_minutes: 5 # how often to enqueue + drain mirror work mirror_workers: 2 # concurrent crane copies per_job_timeout_seconds: 600 + git_sync_minutes: 60 # how often to fetch upstream git repos (when git.enabled) + +# ------------------------------------------------------------------------ +# Git mirror service (air-gap source repos) +# +# When enabled, cc-catalog-svc maintains bare mirror clones of each CC's +# git_url and serves them read-only at mount_path. Platform consumers clone +# from public_base_url/.git instead of github.com once mirrored. +# +# Requires outbound git access only during sync (not at platform runtime). +# ------------------------------------------------------------------------ +# git: +# enabled: true +# data_dir: /opt/cc-catalog/git # baked into release images; NOT under /data PVC +# mount_path: /git +# public_base_url: https://cc-catalog.example.com/git +# runtime_sync: false # air-gap: never fetch github.com at runtime +# auth: +# token_env: GITHUB_TOKEN # build-time only (CI secret); omit at runtime +# # codecollections: [] # optional slug filter; default = all CCs with git_url +# +# See config-examples/config.airgap.yaml for a full air-gap deployment template. +# Release images bake mirrors at docker build from config-examples/config.bake.yaml. # ------------------------------------------------------------------------ # Sources — where to discover image refs from. diff --git a/cc-catalog-svc/docs/ARCHITECTURE.md b/cc-catalog-svc/docs/ARCHITECTURE.md index cd04a64bc12..88c2df75791 100644 --- a/cc-catalog-svc/docs/ARCHITECTURE.md +++ b/cc-catalog-svc/docs/ARCHITECTURE.md @@ -2,8 +2,8 @@ This document explains *why* the service is shaped the way it is. For operator how-tos, see the per-plugin docs (e.g. -[JFROG.md](JFROG.md)) and the top-level -[README.md](../README.md). +[JFROG.md](JFROG.md), [GIT_MIRROR.md](GIT_MIRROR.md)) and the +top-level [README.md](../README.md). --- @@ -134,6 +134,40 @@ Tags that don't match are silently ignored — that's deliberate, so `latest` / `main` / `` tags coexist on the registry without confusing the catalog. +### Tiebreak on multiple builds of the same ref + +When two canonical tags share a ref (e.g. `main--` AND +`main--` both with `ref=main`), the catalog has to choose one +row to persist because `image_refs` is keyed on `(cc_id, ref_name)`. +We sort by `(built_at, image_tag)` ascending and keep the last entry. + +`built_at` is filled in by the OCI source's tiebreak enrichment: + +1. `GET /v2//manifests/` with manifest `Accept` headers. +2. Descend into the manifest's `config.digest` blob (for image + indices, walk into the first child platform manifest first). +3. Read the config blob's `created` field — always set by buildkit / + docker buildx and the only OCI-spec'd source of truth for actual + build time. + +We deliberately do NOT trust the `Last-Modified` HTTP header even +though many registries set it. JFrog Artifactory (and other caching +proxies) sets `Last-Modified` to the local cache freshness time, not +the upstream build time — so whichever tag the poll happened to GET +first or last would win the tiebreak based on cache-warmup order +instead of actual recency. Going to `config.digest.created` adds one +HTTP call per tiebreak tag but is universally correct. + +Enrichment only fires for refs that have >1 canonical tag, so most +polls do zero extra HTTP work. Per-tag failures are tolerated — the +sort just falls back to lex on `image_tag` for whichever tags didn't +get a timestamp. + +Lex-only ordering is wrong for this schema: `cc_sha7` is hex, so +`main-1xxxxxx-...` sorts ASCII-before `main-dxxxxxx-...` even when the +`1xxxxxx` build was pushed weeks later. Without `built_at`, JFrog-fronted +catalogs would happily report a stale tag as `latest_image_tag`. + --- ## Destination plugins diff --git a/cc-catalog-svc/docs/GIT_MIRROR.md b/cc-catalog-svc/docs/GIT_MIRROR.md new file mode 100644 index 00000000000..87ad13c7fd4 --- /dev/null +++ b/cc-catalog-svc/docs/GIT_MIRROR.md @@ -0,0 +1,262 @@ +# cc-catalog-svc — Git mirror hosting + +`cc-catalog-svc` can host read-only git mirrors of every +CodeCollection's source repo and serve them over Smart HTTP. The +intended use-case is air-gapped deployments where the RunWhen platform +needs to `git clone` / `git fetch` CC code but the platform pods don't +have outbound access to github.com. + +Quick links: + +* Example config — [`config-examples/config.airgap.yaml`](../config-examples/config.airgap.yaml) +* Bake manifest — [`config-examples/config.bake.yaml`](../config-examples/config.bake.yaml) +* Source — [`app/git_http/server.py`](../app/git_http/server.py), [`app/services/git_mirror.py`](../app/services/git_mirror.py) + +--- + +## End-to-end shape + +``` + CI build (has internet) air-gap pod (no internet) + ──────────────────────── ─────────────────────────── + bake_git_mirrors.py /opt/cc-catalog/git/ + ⬇ git clone --mirror ├── rw-cli-codecollection.git/ + /opt/cc-catalog/git/*.git ─── COPY ──► ├── rw-public-codecollection.git/ + └── … + ▲ + │ git http-backend (per request) + │ + cc-catalog-svc │ FastAPI + ────────────── │ /git/.git/info/refs + │ /git/.git/git-upload-pack + │ + platform pod (taskiq-worker, gitget) ─────┘ + GET / POST /git/.git/... +``` + +Two life-cycle phases: + +1. **Build-time bake** (CI). `scripts/bake_git_mirrors.py` clones every + repo listed in `config.bake.yaml` into `/opt/cc-catalog/git/` inside + the image. Lives outside `/data` so a PVC mount can't hide it. +2. **Runtime serve** (any cluster). When `git.enabled: true`, + `cc-catalog-svc` mounts a WSGI app on `git.mount_path` (default + `/git`) that delegates each request to the native `git http-backend` + CGI binary against `git.data_dir`. + +The catalog API rewrites `git_url` on every CC response to +`git.public_base_url/.git` whenever a local bare repo exists, so +platform consumers don't need to know about the rewrite at all. + +--- + +## Configuration + +```yaml +git: + enabled: true # off by default + data_dir: /opt/cc-catalog/git # default; matches Dockerfile bake + mount_path: /git # default + public_base_url: https://cc-catalog.example.com/git # required for git_url rewrite + runtime_sync: false # true = fetch on schedule; false = air-gap + codecollections: [] # empty = every CC with a git_url + auth: # optional — for private upstreams + token_env: GITHUB_TOKEN # OR user_env + pass_env + clone_timeout_seconds: 900 + fetch_timeout_seconds: 600 +``` + +| Field | Default | Notes | +|-------|---------|-------| +| `enabled` | `false` | Master switch. When false, no `/git` mount, no rewrite, no scheduler job. | +| `data_dir` | `/opt/cc-catalog/git` | Matches the release image's bake target. Override to a writable path on a PVC when `runtime_sync: true` and you want fetched objects to survive pod restarts. | +| `mount_path` | `/git` | FastAPI mount prefix. Clone URL becomes `public_base_url + /.git`. | +| `public_base_url` | `null` | Required for `git_url` rewrite. Omit to keep upstream URLs in catalog responses even while serving mirrors locally (useful for staged migrations). | +| `runtime_sync` | `true` | When false, the scheduler and the admin endpoint refuse to reach upstream. Use this for air-gap deployments whose mirrors are baked at build time. | +| `codecollections` | `[]` | Allow-list. Empty = every CC with a `git_url`. | +| `auth` | `{}` | Bearer token (`token_env`) or HTTP Basic (`user_env` + `pass_env`). Values are env var **names**, not the secrets themselves. | + +`scheduler.git_sync_minutes` (top-level scheduler block) controls how +often runtime sync runs; ignored when `runtime_sync: false`. + +--- + +## Deployment scenarios + +### Connected cluster (default) + +* `git.enabled: true` +* `git.runtime_sync: true` +* `git.data_dir: /data/git` (on the PVC so fetches persist) +* Image baking is not required — the first scheduler tick clones every + configured repo. + +### Air-gapped cluster (no outbound git) + +* Build the release image with `BAKE_GIT_MIRRORS=true` and a + `config.bake.yaml` listing every CC slug + git_url to bake. +* Deploy with the + [`config.airgap.yaml`](../config-examples/config.airgap.yaml) shape: + * `git.enabled: true` + * `git.runtime_sync: false` + * `git.data_dir: /opt/cc-catalog/git` (default) +* No PVC mount is required for `/data/git`; the bake stage writes + outside `/data`. + +### Mixed (mostly air-gap, occasional refresh) + +If an operator needs to refresh the baked mirrors from upstream after +deploy (e.g. you forgot a CC in the bake manifest), the admin endpoint +exposes an explicit override: + +```bash +# Default — refuses, because runtime_sync is false: +curl -X POST -H "Authorization: Bearer $ADMIN" \ + https://cc-catalog/api/v1/admin/sync-git +# → { "skipped": "runtime_sync disabled ..." } + +# With the explicit override flag: +curl -X POST -H "Authorization: Bearer $ADMIN" \ + 'https://cc-catalog/api/v1/admin/sync-git?allow_runtime_sync=true' +``` + +The override is intentionally opt-in so a stray click in an +air-gapped console can't egress to github.com. + +--- + +## Build-time bake + +The Dockerfile has a dedicated `git-bake` stage: + +```dockerfile +ARG BAKE_GIT_MIRRORS=true +ARG BAKE_CONFIG=config-examples/config.bake.yaml +RUN --mount=type=secret,id=github_token \ + python scripts/bake_git_mirrors.py \ + --config "${BAKE_CONFIG}" \ + --dest /opt/cc-catalog/git +``` + +The runtime stage `COPY --from=git-bake /opt/cc-catalog/git +/opt/cc-catalog/git` to land the bare repos in the final image. + +The bake script: + +* Reads `git_url` from every CodeCollection in the manifest. Unlike + `repos_to_sync`, it does **not** require `git.enabled: true` — the + bake manifest exists solely to list URLs. +* Fetches via `git clone --mirror`, optionally with a `GITHUB_TOKEN` + secret for private repos. +* Exits non-zero if any repo fails to clone, so CI fails loudly rather + than shipping an image with partial mirrors. + +--- + +## Security model + +Smart HTTP is **read-only by design**: + +* `git http-backend` is invoked with `GIT_HTTP_EXPORT_ALL=1` and no + `http.receivepack` enabled, so push (`git-receive-pack`) is rejected + at the CGI layer. +* The WSGI router only accepts these paths: + ``` + /.git/info/refs + /.git/git-upload-pack + /.git/HEAD + ``` + Anything else returns 404 without spawning the CGI process. +* Slugs are validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$` + before being joined onto `data_dir`. Path traversal (`../etc`, + `foo/bar`, etc.) is rejected outright. +* The WSGI app is parameterised with `allowed_slugs` derived from + `repos_to_sync(cfg)`. Stale or operator-staged `*.git` directories + under `data_dir` that aren't in the config are 404, not served. + +The service does not do per-client authentication on `/git`. Front it +with an ingress that enforces network ACLs or mTLS if you need access +control — the same way the catalog API itself is protected. + +--- + +## Operational notes + +### Concurrency + +`run_git_sync` holds a process-wide `threading.Lock` for the entire +run. The scheduler and the admin endpoint both go through this code +path, so they cannot race on the same on-disk bare repo. A second +sync that arrives while the first is still running returns +`{"skipped": "another git sync is already running"}` instead of +queueing. + +### Idempotency + +`sync_one_repo` is safe to call repeatedly: + +* If `/.git` exists but has no `HEAD` / `objects/` + (interrupted clone), the directory is removed and a fresh clone is + attempted. Without this, every subsequent run would `git remote + update` the junked directory forever. +* If the configured upstream URL changes (operator edited + `config.yaml`), `origin` is reset via `git remote set-url` before + the next fetch. Without this, mirrors would silently keep pulling + from the old upstream. +* On success the DB row's `git_head_commit`, `git_last_synced`, and + `git_last_sync_error` are updated. On failure only + `git_last_sync_error` is touched, so the operator sees the failure + without losing the previous good HEAD. + +### Status visibility + +``` +GET /api/v1/git/repos +[ + { + "slug": "rw-cli-codecollection", + "upstream_url": "https://github.com/runwhen-contrib/rw-cli-codecollection", + "public_url": "https://cc-catalog.example.com/git/rw-cli-codecollection.git", + "present": true, + "head_commit": "e2f76a4...", + "last_synced": "2026-05-21T16:10:01", + "last_sync_error": null + }, + ... +] +``` + +`head_commit` is populated from the DB on every successful runtime +sync. For build-time baked mirrors (`runtime_sync: false`), the +service runs `populate_baked_head_commits` at startup so the field +is non-null even before any runtime sync has fired — operators can +trust it. + +### Streaming + +`git http-backend` writes its CGI response to a pipe that we forward +to the WSGI `write()` callback in 64 KiB chunks. We never buffer the +full packfile in memory, so cloning a large CC mirror does not spike +the uvicorn worker's RSS or block other API traffic. + +### Database upgrade + +The `git_head_commit`, `git_last_synced`, and `git_last_sync_error` +columns were added after the initial release. `init_db` issues +idempotent `ALTER TABLE … ADD COLUMN` statements at startup so +already-deployed databases pick up the new columns without an Alembic +migration. The mechanism lives in +[`app/db.py`](../app/db.py) (`_LEGACY_COLUMN_ADDITIONS`) — add to that +dict for any future columns until we adopt Alembic. + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `git clone http://…/git/.git` → 404 | Slug not in `allowed_slugs`. Either missing from `config.yaml`'s sources, or filtered out by `git.codecollections`. | Add the slug to a source, or to the explicit `git.codecollections` allow-list. Restart the pod. | +| `git clone` → `transfer closed with outstanding read data remaining` | Almost always a proxy / ingress timeout, not the service. Check that your ingress allows long-running HTTP responses (some default to 30s; `git fetch` of a large repo can need longer). | Bump ingress `proxy-read-timeout` / `proxy-send-timeout` to e.g. 600s. | +| `GET /api/v1/git/repos` shows `present: false` | The `.git` directory doesn't exist under `data_dir`. | Confirm `git.data_dir` matches where mirrors were baked (`/opt/cc-catalog/git` for release images). For runtime sync, hit `/api/v1/admin/sync-git` and check the response. | +| `POST /admin/sync-git` returns `{"skipped":"runtime_sync disabled …"}` | Air-gap guard rail. | Re-issue with `?allow_runtime_sync=true` if you really want to reach upstream. | +| Logs show `git http-backend exited rc=…` | Most often a permissions issue on the bare repo or a malformed slug. The full stderr is captured at WARN level. | Check `ls -la /.git`; ensure the `catalog` user owns it. | diff --git a/cc-catalog-svc/poetry.lock b/cc-catalog-svc/poetry.lock new file mode 100644 index 00000000000..a6cb31926d9 --- /dev/null +++ b/cc-catalog-svc/poetry.lock @@ -0,0 +1,1619 @@ +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "a2wsgi" +version = "1.10.10" +description = "Convert WSGI app to ASGI app or ASGI app to WSGI app." +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "a2wsgi-1.10.10-py3-none-any.whl", hash = "sha256:d2b21379479718539dc15fce53b876251a0efe7615352dfe49f6ad1bc507848d"}, + {file = "a2wsgi-1.10.10.tar.gz", hash = "sha256:a5bcffb52081ba39df0d5e9a884fc6f819d92e3a42389343ba77cbf809fe1f45"}, +] + +[[package]] +name = "alembic" +version = "1.18.4" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"}, + {file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.4.23" +typing-extensions = ">=4.12" + +[package.extras] +tz = ["tzdata"] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.13.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, + {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, +] + +[package.dependencies] +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + +[[package]] +name = "apscheduler" +version = "3.11.2" +description = "In-process task scheduler with Cron-like capabilities" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d"}, + {file = "apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41"}, +] + +[package.dependencies] +tzlocal = ">=3.0" + +[package.extras] +doc = ["packaging", "sphinx", "sphinx-rtd-theme (>=1.3.0)"] +etcd = ["etcd3", "protobuf (<=3.21.0)"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytest-timeout", "pytz", "twisted ; python_version < \"3.14\""] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + +[[package]] +name = "black" +version = "26.5.1" +description = "The uncompromising code formatter." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893"}, + {file = "black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90"}, + {file = "black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4"}, + {file = "black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef"}, + {file = "black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22"}, + {file = "black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c"}, + {file = "black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7"}, + {file = "black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59"}, + {file = "black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3"}, + {file = "black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe"}, + {file = "black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8"}, + {file = "black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217"}, + {file = "black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d"}, + {file = "black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264"}, + {file = "black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418"}, + {file = "black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3"}, + {file = "black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0"}, + {file = "black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294"}, + {file = "black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a"}, + {file = "black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52"}, + {file = "black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168"}, + {file = "black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3"}, + {file = "black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18"}, + {file = "black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50"}, + {file = "black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae"}, + {file = "black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2"}, + {file = "black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=1.0.0" +platformdirs = ">=2" +pytokens = ">=0.4.0,<0.5.0" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] + +[[package]] +name = "certifi" +version = "2026.5.20" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, + {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, +] + +[[package]] +name = "click" +version = "8.4.0" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81"}, + {file = "click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "fastapi" +version = "0.119.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.119.1-py3-none-any.whl", hash = "sha256:0b8c2a2cce853216e150e9bd4faaed88227f8eb37de21cb200771f491586a27f"}, + {file = "fastapi-0.119.1.tar.gz", hash = "sha256:a5e3426edce3fe221af4e1992c6d79011b247e3b03cc57999d697fe76cbf8ae0"}, +] + +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.49.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "greenlet" +version = "3.5.1" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\"" +files = [ + {file = "greenlet-3.5.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f"}, + {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f"}, + {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c"}, + {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19"}, + {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5"}, + {file = "greenlet-3.5.1-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061"}, + {file = "greenlet-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97"}, + {file = "greenlet-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d"}, + {file = "greenlet-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1"}, + {file = "greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f"}, + {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2"}, + {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33"}, + {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360"}, + {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563"}, + {file = "greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747"}, + {file = "greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071"}, + {file = "greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c"}, + {file = "greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e"}, + {file = "greenlet-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523"}, + {file = "greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2"}, + {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed"}, + {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10"}, + {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249"}, + {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b"}, + {file = "greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee"}, + {file = "greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207"}, + {file = "greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823"}, + {file = "greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b"}, + {file = "greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188"}, + {file = "greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b"}, + {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a"}, + {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283"}, + {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce"}, + {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135"}, + {file = "greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436"}, + {file = "greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd"}, + {file = "greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1"}, + {file = "greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9"}, + {file = "greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e"}, + {file = "greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07"}, + {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea"}, + {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2"}, + {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c"}, + {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c"}, + {file = "greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d"}, + {file = "greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0"}, + {file = "greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc"}, + {file = "greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3"}, + {file = "greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54"}, + {file = "greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad"}, + {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e"}, + {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986"}, + {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f"}, + {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e"}, + {file = "greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de"}, + {file = "greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d"}, + {file = "greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78"}, + {file = "greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2"}, + {file = "greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541"}, + {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de"}, + {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64"}, + {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0"}, + {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5"}, + {file = "greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc"}, + {file = "greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368"}, + {file = "greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26"}, + {file = "greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab"}, + {file = "greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6"}, + {file = "greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed"}, + {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244"}, + {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c"}, + {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c"}, + {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd"}, + {file = "greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62"}, + {file = "greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e"}, + {file = "greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659"}, + {file = "greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e"}, + {file = "greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a"}, + {file = "greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil", "setuptools"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httptools" +version = "0.7.1" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"}, + {file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05"}, + {file = "httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a"}, + {file = "httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b"}, + {file = "httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657"}, + {file = "httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df"}, + {file = "httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274"}, + {file = "httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec"}, + {file = "httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5"}, + {file = "httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03"}, + {file = "httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362"}, + {file = "httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c"}, + {file = "httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3"}, + {file = "httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c"}, + {file = "httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346"}, + {file = "httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650"}, + {file = "httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270"}, + {file = "httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1"}, + {file = "httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60"}, + {file = "httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca"}, + {file = "httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ac50afa68945df63ec7a2707c506bd02239272288add34539a2ef527254626a4"}, + {file = "httptools-0.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de987bb4e7ac95b99b805b99e0aae0ad51ae61df4263459d36e07cf4052d8b3a"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d169162803a24425eb5e4d51d79cbf429fd7a491b9e570a55f495ea55b26f0bf"}, + {file = "httptools-0.7.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49794f9250188a57fa73c706b46cb21a313edb00d337ca4ce1a011fe3c760b28"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aeefa0648362bb97a7d6b5ff770bfb774930a327d7f65f8208394856862de517"}, + {file = "httptools-0.7.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0d92b10dbf0b3da4823cde6a96d18e6ae358a9daa741c71448975f6a2c339cad"}, + {file = "httptools-0.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:5ddbd045cfcb073db2449563dd479057f2c2b681ebc232380e63ef15edc9c023"}, + {file = "httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9"}, +] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.15" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"}, + {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"}, +] + +[package.extras] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "mako" +version = "1.3.12" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9"}, + {file = "mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + +[[package]] +name = "markupsafe" +version = "3.0.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"}, + {file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"}, + {file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"}, + {file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"}, + {file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"}, + {file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"}, + {file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"}, + {file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"}, + {file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"}, + {file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"}, + {file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, + {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"}, + {file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"}, + {file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"}, + {file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"}, + {file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"}, + {file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"}, + {file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"}, + {file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"}, + {file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"}, + {file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"}, + {file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"}, + {file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"}, + {file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"}, + {file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"}, + {file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"}, + {file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"}, + {file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"}, + {file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"}, + {file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"}, + {file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"}, + {file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"}, + {file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"}, + {file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"}, + {file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"}, + {file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"}, + {file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"}, + {file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"}, + {file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"}, + {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "26.2" +description = "Core utilities for Python packages" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + +[[package]] +name = "platformdirs" +version = "4.9.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, + {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"postgres\"" +files = [ + {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1"}, + {file = "psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be"}, + {file = "psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b"}, + {file = "psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290"}, + {file = "psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd"}, + {file = "psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ee2d84ef5eb6c04702d2e9c372ad557fb027f26a5d82804f749dfb14c7fdd2ab"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cfa2517c94ea3af6deb46f81e1bbd884faa63e28481eb2f889989dd8d95e5f03"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:ba3df2fc42a1cfa45b72cf096d4acb2b885937eedc61461081d53538d4a82a86"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:718e1fc18edf573b02cb8aea868de8d8d33f99ce9620206aa9144b67b0985e94"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c7cb4cbf894a1d36c720d713de507952c7c58f66d30834708f03dbe5c822ccf"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:049366c6d884bdcd65d66e6ca1fdbebe670b56c6c9ba46f164e6667e90881964"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fb1828cf3da68f99e45ebce1355d65d2d12b6a78fb5dfb16247aad6bdef5f5d2"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:127467c6e476dd876634f17c3d870530e73ff454ff99bff73d36e80af28e1115"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:ace94261f43850e9e79f6c56636c5e0147978ab79eda5e5e5ebf13ae146fc8fe"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7e39a65b7d2a20e4ba2e0aaad1960b61cc2888d6ab047769f8347bd3c9ad915"}, + {file = "psycopg2_binary-2.9.12-cp39-cp39-win_amd64.whl", hash = "sha256:f625abb7020e4af3432d95342daa1aa0db3fa369eed19807aa596367ba791b10"}, + {file = "psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c"}, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, + {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.46.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, + {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de"}, + {file = "pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "types-boto3[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.3" +description = "pytest: simple powerful testing with Python" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, + {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +description = "Pytest support for asyncio" +optional = true +python-versions = ">=3.10" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, +] + +[package.dependencies] +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "pytokens" +version = "0.4.1" +description = "A Fast, spec compliant Python 3.14+ tokenizer that runs on older Pythons." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe"}, + {file = "pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c"}, + {file = "pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7"}, + {file = "pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2"}, + {file = "pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc"}, + {file = "pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d"}, + {file = "pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16"}, + {file = "pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6"}, + {file = "pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1"}, + {file = "pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1"}, + {file = "pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9"}, + {file = "pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68"}, + {file = "pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f"}, + {file = "pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1"}, + {file = "pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4"}, + {file = "pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78"}, + {file = "pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa"}, + {file = "pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d"}, + {file = "pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324"}, + {file = "pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9"}, + {file = "pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3"}, + {file = "pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975"}, + {file = "pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a"}, + {file = "pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918"}, + {file = "pytokens-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009"}, + {file = "pytokens-0.4.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1"}, + {file = "pytokens-0.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6"}, + {file = "pytokens-0.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037"}, + {file = "pytokens-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1"}, + {file = "pytokens-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db"}, + {file = "pytokens-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1"}, + {file = "pytokens-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a"}, + {file = "pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de"}, + {file = "pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a"}, +] + +[package.extras] +dev = ["black", "build", "mypy", "pytest", "pytest-cov", "setuptools", "tox", "twine", "wheel"] + +[[package]] +name = "pyyaml" +version = "6.0.3" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"}, + {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"}, + {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"}, + {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"}, + {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, + {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, + {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, + {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, + {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, + {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, + {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, + {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, + {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, + {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, + {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, + {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, + {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, + {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, + {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, + {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, + {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, + {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, + {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, + {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, + {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, + {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, + {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, + {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, + {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, + {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "respx" +version = "0.23.1" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a"}, + {file = "respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780"}, +] + +[package.dependencies] +httpx = ">=0.25.0" + +[[package]] +name = "ruff" +version = "0.15.14" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"dev\"" +files = [ + {file = "ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108"}, + {file = "ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b"}, + {file = "ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f"}, + {file = "ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d"}, + {file = "ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4"}, + {file = "ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542"}, + {file = "ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f"}, + {file = "ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf"}, + {file = "ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba"}, + {file = "ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f"}, + {file = "ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581"}, + {file = "ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93"}, + {file = "ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61"}, + {file = "ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553"}, + {file = "ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6"}, + {file = "ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902"}, + {file = "ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826"}, + {file = "ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3"}, + {file = "sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a"}, + {file = "sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0"}, + {file = "sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b"}, + {file = "sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d"}, + {file = "sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5"}, + {file = "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158"}, + {file = "sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-win32.whl", hash = "sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662"}, + {file = "sqlalchemy-2.0.49-cp38-cp38-win_amd64.whl", hash = "sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-win32.whl", hash = "sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3"}, + {file = "sqlalchemy-2.0.49-cp39-cp39-win_amd64.whl", hash = "sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f"}, + {file = "sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0"}, + {file = "sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "starlette" +version = "0.48.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"}, + {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "tzdata" +version = "2026.2" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, + {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + +[[package]] +name = "uvicorn" +version = "0.39.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uvicorn-0.39.0-py3-none-any.whl", hash = "sha256:7beec21bd2693562b386285b188a7963b06853c0d006302b3e4cfed950c9929a"}, + {file = "uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302"}, +] + +[package.dependencies] +click = ">=7.0" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "uvloop" +version = "0.22.1" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.1" +groups = ["main"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"}, + {file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86"}, + {file = "uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2"}, + {file = "uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9"}, + {file = "uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21"}, + {file = "uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733"}, + {file = "uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"}, + {file = "uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370"}, + {file = "uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2"}, + {file = "uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705"}, + {file = "uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d"}, + {file = "uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e"}, + {file = "uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142"}, + {file = "uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35"}, + {file = "uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6"}, + {file = "uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289"}, + {file = "uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c"}, + {file = "uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88"}, + {file = "uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa"}, + {file = "uvloop-0.22.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820"}, + {file = "uvloop-0.22.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242"}, + {file = "uvloop-0.22.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4"}, + {file = "uvloop-0.22.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54"}, + {file = "uvloop-0.22.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743"}, + {file = "uvloop-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7"}, + {file = "uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx_rtd_theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=6.1,<7.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=25.3.0,<25.4.0)", "pycodestyle (>=2.11.0,<2.12.0)"] + +[[package]] +name = "watchfiles" +version = "1.2.0" +description = "Simple, modern and high performance file watching and code reload in python." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "watchfiles-1.2.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9"}, + {file = "watchfiles-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4"}, + {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631"}, + {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994"}, + {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e"}, + {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19"}, + {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8"}, + {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07"}, + {file = "watchfiles-1.2.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551"}, + {file = "watchfiles-1.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310"}, + {file = "watchfiles-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df"}, + {file = "watchfiles-1.2.0-cp310-cp310-win32.whl", hash = "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1"}, + {file = "watchfiles-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d"}, + {file = "watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201"}, + {file = "watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5"}, + {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a"}, + {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1"}, + {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717"}, + {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b"}, + {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5"}, + {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e"}, + {file = "watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165"}, + {file = "watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6"}, + {file = "watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5"}, + {file = "watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8"}, + {file = "watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22"}, + {file = "watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7"}, + {file = "watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26"}, + {file = "watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c"}, + {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc"}, + {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0"}, + {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c"}, + {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01"}, + {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8"}, + {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5"}, + {file = "watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d"}, + {file = "watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c"}, + {file = "watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906"}, + {file = "watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898"}, + {file = "watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379"}, + {file = "watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5"}, + {file = "watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98"}, + {file = "watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44"}, + {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658"}, + {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb"}, + {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f"}, + {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0"}, + {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5"}, + {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71"}, + {file = "watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3"}, + {file = "watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0"}, + {file = "watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427"}, + {file = "watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799"}, + {file = "watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9"}, + {file = "watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077"}, + {file = "watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08"}, + {file = "watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9"}, + {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4"}, + {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55"}, + {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925"}, + {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4"}, + {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2"}, + {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9"}, + {file = "watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa"}, + {file = "watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44"}, + {file = "watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72"}, + {file = "watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4"}, + {file = "watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281"}, + {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d"}, + {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e"}, + {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242"}, + {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add"}, + {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f"}, + {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7"}, + {file = "watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e"}, + {file = "watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06"}, + {file = "watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba"}, + {file = "watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7"}, + {file = "watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103"}, + {file = "watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3"}, + {file = "watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2"}, + {file = "watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28"}, + {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831"}, + {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33"}, + {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4"}, + {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b"}, + {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666"}, + {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925"}, + {file = "watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b"}, + {file = "watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30"}, + {file = "watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5"}, + {file = "watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374"}, + {file = "watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65"}, + {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69"}, + {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579"}, + {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7"}, + {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2"}, + {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6"}, + {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4"}, + {file = "watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488"}, + {file = "watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb"}, + {file = "watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377"}, + {file = "watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2"}, + {file = "watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db"}, + {file = "watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7"}, + {file = "watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0"}, + {file = "watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838"}, +] + +[package.dependencies] +anyio = ">=3.0.0" + +[[package]] +name = "websockets" +version = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] + +[extras] +dev = ["black", "httpx", "pytest", "pytest-asyncio", "respx", "ruff"] +postgres = ["psycopg2-binary"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.11" +content-hash = "e5bb5efab99f4dd4ecb5531ff3f7a5947b7c1dc058f9e63da67f2199111da363" diff --git a/cc-catalog-svc/pyproject.toml b/cc-catalog-svc/pyproject.toml index d68b9fbdc67..8ebf2b0e1f3 100644 --- a/cc-catalog-svc/pyproject.toml +++ b/cc-catalog-svc/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "pyyaml>=6.0,<7", # Optional Postgres support: import is lazy in db.py "psycopg2-binary>=2.9; extra == 'postgres'", + "a2wsgi (>=1.10,<2)", ] [project.optional-dependencies] diff --git a/cc-catalog-svc/scripts/bake_git_mirrors.py b/cc-catalog-svc/scripts/bake_git_mirrors.py new file mode 100644 index 00000000000..550ee04ebca --- /dev/null +++ b/cc-catalog-svc/scripts/bake_git_mirrors.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Bake bare git mirrors into the container image at build time. + +CI has outbound access; the running air-gapped container does not. +This script is invoked from the Dockerfile git-bake stage. +""" +from __future__ import annotations + +import argparse +import sys + +from app.config import AppConfig, load_config +from app.services.git_mirror import sync_one_repo + + +def repos_to_bake(cfg: AppConfig) -> list[tuple[str, str]]: + """Return ``(slug, upstream_url)`` pairs for image bake. + + Unlike ``repos_to_sync``, this does not require ``git.enabled`` — the bake + manifest only lists sources and is never used at runtime. + """ + explicit = cfg.git.codecollections if cfg.git.codecollections else None + pairs: list[tuple[str, str]] = [] + for slug, cc in cfg.all_codecollections().items(): + if explicit is not None and slug not in explicit: + continue + if cc.git_url: + pairs.append((slug, cc.git_url)) + return pairs + + +def main() -> int: + parser = argparse.ArgumentParser(description="Clone bare git mirrors for image bake") + parser.add_argument( + "--config", + required=True, + help="YAML config listing CodeCollection git_url values to mirror", + ) + parser.add_argument( + "--dest", + required=True, + help="Output directory for bare repos (.git)", + ) + args = parser.parse_args() + + cfg = load_config(args.config) + git_cfg = cfg.git.model_copy(update={"data_dir": args.dest}) + pairs = repos_to_bake(cfg) + if not pairs: + print( + "bake_git_mirrors: no codecollections with git_url in bake config", + file=sys.stderr, + ) + return 1 + + errors: list[str] = [] + for slug, upstream_url in pairs: + print(f"bake_git_mirrors: mirroring {slug} <- {upstream_url}", flush=True) + try: + head = sync_one_repo(slug, upstream_url, git_cfg) + print(f"bake_git_mirrors: {slug} @ {head[:12]}", flush=True) + except Exception as exc: + errors.append(f"{slug}: {exc}") + print(f"bake_git_mirrors: FAILED {slug}: {exc}", file=sys.stderr, flush=True) + + if errors: + print(f"bake_git_mirrors: {len(errors)} repo(s) failed", file=sys.stderr) + return 1 + print(f"bake_git_mirrors: baked {len(pairs)} repo(s) to {args.dest}", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cc-catalog-svc/tests/test_bake_git_mirrors.py b/cc-catalog-svc/tests/test_bake_git_mirrors.py new file mode 100644 index 00000000000..18357f7437d --- /dev/null +++ b/cc-catalog-svc/tests/test_bake_git_mirrors.py @@ -0,0 +1,33 @@ +"""Tests for build-time git mirror bake.""" + +from __future__ import annotations + +from pathlib import Path + +from app.config import load_config +from scripts.bake_git_mirrors import repos_to_bake + + +def test_repos_to_bake_reads_codecollections_without_runtime_git_enabled(): + cfg_path = Path(__file__).resolve().parents[1] / "config-examples" / "config.bake.yaml" + cfg = load_config(str(cfg_path)) + assert cfg.git.enabled is True + pairs = repos_to_bake(cfg) + slugs = {slug for slug, _ in pairs} + assert "rw-cli-codecollection" in slugs + # Asserts the shipped example covers every CC the bake step will see. + # If you add a new CC in config.bake.yaml, bump this number — the + # bake step must know about it ahead of build time. + assert len(pairs) == len(slugs) == 7 + + +def test_git_auth_args_uses_github_basic_token(monkeypatch): + from app.config import GitAuth + from app.services import git_mirror as gm + + monkeypatch.setenv("GITHUB_TOKEN", "ghp_test") + args = gm._git_auth_args(GitAuth(token_env="GITHUB_TOKEN")) + assert len(args) == 2 + assert args[0] == "-c" + assert args[1].startswith("http.extraHeader=Authorization: Basic ") + assert "Bearer" not in args[1] diff --git a/cc-catalog-svc/tests/test_catalog_poll_and_api.py b/cc-catalog-svc/tests/test_catalog_poll_and_api.py index 9cbf8f2fd31..29d72638ad0 100644 --- a/cc-catalog-svc/tests/test_catalog_poll_and_api.py +++ b/cc-catalog-svc/tests/test_catalog_poll_and_api.py @@ -228,6 +228,105 @@ def test_entry_pointers_fallback_never_compares_ref_name_to_image_tag(): assert stable == "v2.0.0-bbccdde-e4f5a6b" +class TiebreakSource(ImageSource): + """In-memory source that returns two competing tags for ref=main. + + Used to verify ``_upsert_refs`` picks the row with the newer + ``built_at`` rather than the one with the lexicographically-largest + ``image_tag``. The older tag's image_tag is intentionally larger in + ASCII (``main-d...`` > ``main-1...``) — that's the trap the bug fell + into in production behind JFrog. + """ + + name = "tiebreak" + + def discover_refs(self, cc): + return [ + DiscoveredImageRef( + ref="main", + ref_type="branch", + commit="de76dd0", + rt_revision="71dfdc4", + image_tag="main-de76dd0-71dfdc4", # lex-larger, older + built_at=datetime(2026, 5, 12, 10, 0, 0), + ), + DiscoveredImageRef( + ref="main", + ref_type="branch", + commit="10792f4", + rt_revision="6e4bc81", + image_tag="main-10792f4-6e4bc81", # lex-smaller, newer + built_at=datetime(2026, 5, 21, 17, 0, 0), + ), + ] + + def resolve_latest(self, cc, refs): + # Match the OCISource ordering exactly. + from datetime import timezone + + candidates = [r for r in refs if r.ref == cc.get("default_ref", "main")] + candidates.sort( + key=lambda r: ( + r.built_at or datetime.min.replace(tzinfo=timezone.utc), + r.image_tag, + ) + ) + return candidates[-1].image_tag if candidates else None + + def resolve_stable(self, cc, refs): + return None + + +def test_upsert_refs_picks_newest_by_built_at_not_lex(monkeypatch, db_session): + """Regression: catalog must not be tricked into keeping the older + canonical tag just because its cc_sha7 prefix is ASCII-larger. + + Before the fix, the JFrog-fronted catalog kept reporting + ``main-de76dd0-71dfdc4`` as the ``main`` ref's row even after the + newer ``main-10792f4-6e4bc81`` showed up in /v2/.../tags/list, + because ``d`` > ``1`` lexicographically. The fix wires + ``DiscoveredImageRef.built_at`` into the tiebreak so the surviving + row matches what ``resolve_latest`` declared. + """ + src = TiebreakSource() + monkeypatch.setitem(src_registry.SOURCE_REGISTRY, src.name, src) + cfg = AppConfig( + storage=StorageConfig(), + scheduler=SchedulerConfig(), + sources=[ + SourceConfig( + name="tiebreak-src", + type="tiebreak", + codecollections=[ + CodeCollectionConfig( + slug="ss-rw-cli-codecollection", + name="SheaStewart RW CLI", + image_registry="jfrog.example.com/stewartshea/rw-cli-codecollection", + ) + ], + ) + ], + ) + config_mod._CONFIG_CACHE = cfg + + summary = run_catalog_poll(cfg) + assert summary["collections_processed"] == 1 + assert summary["errors"] == [] + + from app.models import CodeCollection, ImageRef + from sqlalchemy import select + + cc = db_session.execute( + select(CodeCollection).where(CodeCollection.slug == "ss-rw-cli-codecollection") + ).scalar_one() + main_row = db_session.execute( + select(ImageRef).where(ImageRef.cc_id == cc.id, ImageRef.ref_name == "main") + ).scalar_one() + assert main_row.image_tag == "main-10792f4-6e4bc81" + assert main_row.commit_hash == "10792f4" + assert main_row.is_latest is True + + def test_duplicate_cc_slug_across_sources_fails_loudly(): cfg = AppConfig( sources=[ diff --git a/cc-catalog-svc/tests/test_git_http.py b/cc-catalog-svc/tests/test_git_http.py new file mode 100644 index 00000000000..62641448924 --- /dev/null +++ b/cc-catalog-svc/tests/test_git_http.py @@ -0,0 +1,254 @@ +"""Integration tests for git smart HTTP serving.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import threading +import time + +import pytest +from a2wsgi import WSGIMiddleware +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.git_http import make_git_wsgi_app, repo_bare_path +from app.git_http.server import is_valid_slug, list_bare_repo_slugs + + +def _init_bare_repo(path: str) -> None: + with tempfile.TemporaryDirectory() as workdir: + subprocess.run(["git", "init"], cwd=workdir, check=True, capture_output=True) + env = { + **os.environ, + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "test@example.com", + } + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init"], + cwd=workdir, + check=True, + capture_output=True, + env=env, + ) + subprocess.run( + ["git", "clone", "--bare", workdir, path], + check=True, + capture_output=True, + ) + + +def _init_bare_repo_many_branches(path: str, *, branches: int = 40) -> None: + """Bare repo large enough that git gzip-compresses upload-pack POST bodies.""" + with tempfile.TemporaryDirectory() as workdir: + subprocess.run(["git", "init"], cwd=workdir, check=True, capture_output=True) + env = { + **os.environ, + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "test@example.com", + } + subprocess.run( + ["git", "commit", "--allow-empty", "-m", "init"], + cwd=workdir, + check=True, + capture_output=True, + env=env, + ) + for i in range(branches): + subprocess.run( + ["git", "branch", f"branch-{i}"], + cwd=workdir, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "clone", "--bare", workdir, path], + check=True, + capture_output=True, + ) + + +def _mount_test_server(tmp_path: str) -> tuple[FastAPI, int]: + import socket + + import uvicorn + + api = FastAPI() + api.mount("/git", WSGIMiddleware(make_git_wsgi_app(tmp_path))) + + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + + config = uvicorn.Config(api, host="127.0.0.1", port=port, log_level="error") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + time.sleep(0.5) + return api, port + + +def test_bare_repos_discovered_by_slug(tmp_path): + bare = repo_bare_path(str(tmp_path), "demo-cc") + _init_bare_repo(bare) + + slugs = list_bare_repo_slugs(str(tmp_path)) + assert slugs == ["demo-cc"] + + head = subprocess.run( + ["git", "--git-dir", bare, "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + assert head + + assert callable(make_git_wsgi_app(str(tmp_path))) + + +def test_info_refs_via_a2wsgi_mount(tmp_path): + bare = repo_bare_path(str(tmp_path), "demo-cc") + _init_bare_repo(bare) + + api = FastAPI() + api.mount("/git", WSGIMiddleware(make_git_wsgi_app(str(tmp_path)))) + + with TestClient(api) as client: + resp = client.get( + "/git/demo-cc.git/info/refs", + params={"service": "git-upload-pack"}, + headers={"Accept": "*/*"}, + ) + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("application/x-git-upload-pack-advertisement") + assert b"# service=git-upload-pack" in resp.content + assert b"refs/heads/" in resp.content + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git binary required") +def test_git_clone_via_a2wsgi_mount(tmp_path): + bare = repo_bare_path(str(tmp_path), "many-branches") + _init_bare_repo_many_branches(bare) + + _, port = _mount_test_server(str(tmp_path)) + dest = tmp_path / "clone" + result = subprocess.run( + [ + "git", + "clone", + f"http://127.0.0.1:{port}/git/many-branches.git", + str(dest), + ], + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert (dest / ".git").is_dir() + + +def test_repo_bare_path_rejects_traversal(tmp_path): + """Slug must not be allowed to escape data_dir via .. or path separators.""" + for bad in ( + "../etc", + "..", + "foo/bar", + "foo\\bar", + ".hidden", + "", + " spaces ", + ): + with pytest.raises(ValueError): + repo_bare_path(str(tmp_path), bad) + + +def test_is_valid_slug(): + assert is_valid_slug("demo-cc") + assert is_valid_slug("rw_cli.codecollection-001") + assert not is_valid_slug("../etc") + assert not is_valid_slug("foo/bar") + assert not is_valid_slug("") + assert not is_valid_slug(".hidden") + + +def test_app_404s_for_path_traversal_attempts(tmp_path): + """Unmatched / suspicious paths return 404 (not 500, no shell-out).""" + bare = repo_bare_path(str(tmp_path), "demo-cc") + _init_bare_repo(bare) + + api = FastAPI() + api.mount("/git", WSGIMiddleware(make_git_wsgi_app(str(tmp_path)))) + with TestClient(api) as client: + for bad in ( + "/git/../etc/passwd", + "/git/demo-cc.git/../../etc/passwd", + "/git/demo-cc.git/objects/pack/pack-xxx.idx", + "/git/demo-cc.git/config", + "/git/demo-cc.git/HEAD/../etc", + ): + resp = client.get(bad) + assert resp.status_code in (404, 400), f"{bad} returned {resp.status_code}" + + +def test_allowed_slugs_filters_unknown_repos(tmp_path): + """Repos on disk but not in allowed_slugs must be 404, not served.""" + _init_bare_repo(repo_bare_path(str(tmp_path), "allowed-cc")) + _init_bare_repo(repo_bare_path(str(tmp_path), "leftover-cc")) + + api = FastAPI() + api.mount( + "/git", + WSGIMiddleware( + make_git_wsgi_app(str(tmp_path), allowed_slugs={"allowed-cc"}) + ), + ) + with TestClient(api) as client: + ok = client.get( + "/git/allowed-cc.git/info/refs", + params={"service": "git-upload-pack"}, + ) + denied = client.get( + "/git/leftover-cc.git/info/refs", + params={"service": "git-upload-pack"}, + ) + assert ok.status_code == 200 + assert denied.status_code == 404 + + +@pytest.mark.skipif(shutil.which("git") is None, reason="git binary required") +def test_shallow_fetch_depth2_tags_via_a2wsgi_mount(tmp_path): + """Platform gitget uses ``fetch(depth=2, tags=True)`` — must not crash server.""" + bare = repo_bare_path(str(tmp_path), "many-branches") + _init_bare_repo_many_branches(bare) + + _, port = _mount_test_server(str(tmp_path)) + repo = tmp_path / "work" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True) + subprocess.run( + [ + "git", + "remote", + "add", + "origin", + f"http://127.0.0.1:{port}/git/many-branches.git", + ], + cwd=repo, + check=True, + ) + result = subprocess.run( + ["git", "fetch", "-v", "--depth=2", "--tags", "--", "origin"], + cwd=repo, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr diff --git a/cc-catalog-svc/tests/test_git_mirror.py b/cc-catalog-svc/tests/test_git_mirror.py new file mode 100644 index 00000000000..6a0c1928def --- /dev/null +++ b/cc-catalog-svc/tests/test_git_mirror.py @@ -0,0 +1,436 @@ +"""Tests for git mirror sync + catalog git_url rewrite.""" + +from __future__ import annotations + +import os +import subprocess +import threading + +import pytest +import yaml + +from app.config import ( + AppConfig, + GitServiceConfig, + load_config, +) +from app import config as config_mod +from app.models import CodeCollection +from app.services.git_mirror import ( + public_git_url, + repos_to_sync, + resolve_git_url_for_catalog, + run_git_sync, +) +from app.git_http import repo_bare_path, repo_exists +from fastapi.testclient import TestClient + + +@pytest.fixture +def git_enabled_config(tmp_path, engine) -> AppConfig: + git_dir = tmp_path / "git" + git_dir.mkdir() + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "git": { + "enabled": True, + "data_dir": str(git_dir), + "public_base_url": "https://catalog.example.com/git", + }, + "sources": [ + { + "name": "test", + "type": "oci", + "codecollections": [ + { + "slug": "demo-cc", + "git_url": "https://github.com/example/demo-cc", + "image_registry": "ghcr.io/example/demo-cc", + } + ], + } + ], + } + ) + ) + cfg = load_config(str(config_path)) + config_mod._CONFIG_CACHE = cfg + return cfg + + +@pytest.fixture +def git_client(engine, git_enabled_config, monkeypatch): + monkeypatch.setenv("CC_CATALOG_DISABLE_SCHEDULER", "1") + from app.main import app + + with TestClient(app) as c: + yield c + + +def test_public_git_url(): + cfg = GitServiceConfig( + enabled=True, + public_base_url="https://catalog.example.com/git/", + ) + assert public_git_url("demo-cc", cfg) == "https://catalog.example.com/git/demo-cc.git" + + +def test_repos_to_sync_all_with_git_url(git_enabled_config): + pairs = repos_to_sync(git_enabled_config) + assert pairs == [("demo-cc", "https://github.com/example/demo-cc")] + + +def test_repos_to_sync_respects_explicit_list(git_enabled_config): + git_enabled_config.git.codecollections = ["demo-cc"] + assert repos_to_sync(git_enabled_config) == [("demo-cc", "https://github.com/example/demo-cc")] + + +def test_resolve_git_url_falls_back_without_mirror(git_enabled_config, db_session): + cc = CodeCollection( + slug="demo-cc", + git_url="https://github.com/example/demo-cc", + ) + db_session.add(cc) + db_session.commit() + assert resolve_git_url_for_catalog(cc, git_enabled_config) == ( + "https://github.com/example/demo-cc" + ) + + +def test_resolve_git_url_uses_public_mirror_when_present(git_enabled_config, db_session, tmp_path): + bare = repo_bare_path(git_enabled_config.git.data_dir, "demo-cc") + os.makedirs(bare, exist_ok=True) + with open(os.path.join(bare, "HEAD"), "w") as f: + f.write("ref: refs/heads/main\n") + + cc = CodeCollection( + slug="demo-cc", + git_url="https://github.com/example/demo-cc", + ) + db_session.add(cc) + db_session.commit() + + assert resolve_git_url_for_catalog(cc, git_enabled_config) == ( + "https://catalog.example.com/git/demo-cc.git" + ) + + +def test_run_git_sync_skips_when_runtime_sync_disabled(git_enabled_config, monkeypatch): + git_enabled_config.git.runtime_sync = False + config_mod._CONFIG_CACHE = git_enabled_config + + def fail_git(*args, **kwargs): + raise AssertionError("git should not run when runtime_sync is false") + + monkeypatch.setattr("app.services.git_mirror.sync_one_repo", fail_git) + summary = run_git_sync(git_enabled_config) + assert summary["skipped"] + assert summary["repos_processed"] == 0 + + +def test_run_git_sync_force_alone_does_not_bypass_air_gap( + git_enabled_config, db_session, monkeypatch +): + """``force=True`` must NOT bypass runtime_sync=False on its own. + + The admin endpoint defaults to allow_runtime_sync=False so a stray + click in an air-gap deployment can't egress to github.com. + """ + git_enabled_config.git.runtime_sync = False + config_mod._CONFIG_CACHE = git_enabled_config + + def fail_git(*args, **kwargs): + raise AssertionError("git must not run when runtime_sync is false") + + monkeypatch.setattr("app.services.git_mirror.sync_one_repo", fail_git) + summary = run_git_sync(git_enabled_config, force=True) + assert "skipped" in summary + assert summary["repos_processed"] == 0 + + +def test_run_git_sync_allow_runtime_sync_overrides_air_gap( + git_enabled_config, db_session, monkeypatch +): + git_enabled_config.git.runtime_sync = False + config_mod._CONFIG_CACHE = git_enabled_config + + monkeypatch.setattr( + "app.services.git_mirror.sync_one_repo", + lambda slug, url, git_cfg: "deadbeef", + ) + summary = run_git_sync( + git_enabled_config, force=True, allow_runtime_sync=True + ) + assert summary["repos_updated"] == 1 + assert "skipped" not in summary + + +def test_run_git_sync_clone_and_update(git_enabled_config, monkeypatch): + calls: list[list[str]] = [] + + def fake_run(cmd, auth, *, timeout): + calls.append(cmd) + if cmd[:2] == ["clone", "--mirror"]: + dest = cmd[3] + os.makedirs(dest, exist_ok=True) + os.makedirs(os.path.join(dest, "objects"), exist_ok=True) + with open(os.path.join(dest, "HEAD"), "w") as f: + f.write("ref: refs/heads/main\n") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["-C", cmd[1], "remote"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:2] == ["--git-dir", cmd[1]]: + return subprocess.CompletedProcess(cmd, 0, stdout="abc1234\n", stderr="") + raise AssertionError(f"unexpected git args: {cmd}") + + monkeypatch.setattr("app.services.git_mirror._run_git", fake_run) + # ``sync_one_repo`` calls subprocess.run directly to peek at the + # existing origin URL before deciding to fetch; stub it to a no-op + # that reports the current upstream so no set-url is issued. + def fake_remote_geturl(cmd, **kwargs): + if cmd[:5] == ["git", "--git-dir", cmd[2], "remote", "get-url"]: + return subprocess.CompletedProcess( + cmd, 0, stdout="https://github.com/example/demo-cc\n", stderr="" + ) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr("app.services.git_mirror.subprocess.run", fake_remote_geturl) + + summary = run_git_sync(git_enabled_config) + assert summary["repos_processed"] == 1 + assert summary["repos_updated"] == 1 + assert summary["errors"] == [] + assert repo_exists(git_enabled_config.git.data_dir, "demo-cc") + + summary2 = run_git_sync(git_enabled_config) + assert summary2["repos_updated"] == 1 + assert any( + cmd[:3] == ["-C", repo_bare_path(git_enabled_config.git.data_dir, "demo-cc"), "remote"] + for cmd in calls[1:] + ) + + +def test_git_api_disabled_returns_404(client): + resp = client.get("/api/v1/git/repos") + assert resp.status_code == 404 + + +def test_git_api_lists_repos(git_enabled_config, git_client): + bare = repo_bare_path(git_enabled_config.git.data_dir, "demo-cc") + os.makedirs(bare, exist_ok=True) + with open(os.path.join(bare, "HEAD"), "w") as f: + f.write("ref: refs/heads/main\n") + + resp = git_client.get("/api/v1/git/repos") + assert resp.status_code == 200 + body = resp.json() + assert len(body) == 1 + assert body[0]["slug"] == "demo-cc" + assert body[0]["present"] is True + assert body[0]["public_url"] == "https://catalog.example.com/git/demo-cc.git" + + +def test_admin_sync_git(git_enabled_config, client, monkeypatch): + captured: dict = {} + + def fake_run(**kwargs): + captured.update(kwargs) + return { + "enabled": True, + "repos_processed": 0, + "repos_updated": 0, + "errors": [], + } + + monkeypatch.setattr("app.routers.admin.run_git_sync", fake_run) + resp = client.post( + "/api/v1/admin/sync-git", + headers={"Authorization": "Bearer admin-test-token"}, + ) + assert resp.status_code == 200 + # Default: force=True, allow_runtime_sync=False (air-gap safe). + assert captured == {"force": True, "allow_runtime_sync": False} + + +def test_admin_sync_git_allow_runtime_sync_flag(git_enabled_config, client, monkeypatch): + captured: dict = {} + + def fake_run(**kwargs): + captured.update(kwargs) + return {"enabled": True, "repos_processed": 0, "repos_updated": 0, "errors": []} + + monkeypatch.setattr("app.routers.admin.run_git_sync", fake_run) + resp = client.post( + "/api/v1/admin/sync-git?allow_runtime_sync=true", + headers={"Authorization": "Bearer admin-test-token"}, + ) + assert resp.status_code == 200 + assert captured == {"force": True, "allow_runtime_sync": True} + + +# --------------------------------------------------------------------------- +# Bugbot regressions +# --------------------------------------------------------------------------- +def test_sync_one_repo_recovers_from_incomplete_bare_clone( + git_enabled_config, monkeypatch +): + """A bare dir without HEAD must be removed and re-cloned.""" + from app.services import git_mirror + + dest = repo_bare_path(git_enabled_config.git.data_dir, "demo-cc") + os.makedirs(dest, exist_ok=True) + # No HEAD, no objects — looks like an interrupted clone. + + cloned: list[list[str]] = [] + + def fake_run(cmd, auth, *, timeout): + cloned.append(cmd) + if cmd[:2] == ["clone", "--mirror"]: + clone_dest = cmd[3] + os.makedirs(clone_dest, exist_ok=True) + os.makedirs(os.path.join(clone_dest, "objects"), exist_ok=True) + with open(os.path.join(clone_dest, "HEAD"), "w") as f: + f.write("ref: refs/heads/main\n") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="abc1234\n", stderr="") + + monkeypatch.setattr(git_mirror, "_run_git", fake_run) + head = git_mirror.sync_one_repo( + "demo-cc", + "https://github.com/example/demo-cc", + git_enabled_config.git, + ) + assert head == "abc1234" + # First git invocation after the cleanup must be a fresh clone, not + # a `remote update` against the junked directory. + assert cloned[0][:2] == ["clone", "--mirror"] + + +def test_sync_one_repo_resets_origin_when_upstream_changes( + git_enabled_config, monkeypatch +): + """Existing mirror whose origin no longer matches config gets reset.""" + from app.services import git_mirror + + dest = repo_bare_path(git_enabled_config.git.data_dir, "demo-cc") + os.makedirs(os.path.join(dest, "objects"), exist_ok=True) + with open(os.path.join(dest, "HEAD"), "w") as f: + f.write("ref: refs/heads/main\n") + + # Stub subprocess.run so we observe set-url being issued. + issued: list[list[str]] = [] + + def fake_run(cmd, check=False, capture_output=False, text=False, timeout=None, env=None): + issued.append(cmd) + if cmd[:4] == ["git", "--git-dir", dest, "remote"] and cmd[4] == "get-url": + return subprocess.CompletedProcess(cmd, 0, stdout="https://old.example.com/x\n", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(git_mirror.subprocess, "run", fake_run) + monkeypatch.setattr( + git_mirror, + "_run_git", + lambda cmd, auth, *, timeout: subprocess.CompletedProcess( + cmd, 0, stdout="abc1234\n", stderr="" + ), + ) + + head = git_mirror.sync_one_repo( + "demo-cc", + "https://github.com/example/demo-cc", + git_enabled_config.git, + ) + assert head == "abc1234" + assert any( + cmd[:5] == ["git", "--git-dir", dest, "remote", "set-url"] + and cmd[6] == "https://github.com/example/demo-cc" + for cmd in issued + ) + + +def test_run_git_sync_lock_prevents_overlap(git_enabled_config, monkeypatch): + """Second concurrent run_git_sync must skip rather than race the first.""" + from app.services import git_mirror + + holding = threading.Event() + release = threading.Event() + + def slow_sync(slug, url, git_cfg): + holding.set() + release.wait(timeout=5) + return "abc1234" + + monkeypatch.setattr(git_mirror, "sync_one_repo", slow_sync) + + first_result: dict = {} + second_result: dict = {} + + def run_first(): + first_result.update(git_mirror.run_git_sync(git_enabled_config)) + + t1 = threading.Thread(target=run_first) + t1.start() + assert holding.wait(timeout=5) + second_result.update(git_mirror.run_git_sync(git_enabled_config)) + release.set() + t1.join(timeout=5) + + assert second_result.get("skipped") == "another git sync is already running" + assert first_result["repos_updated"] == 1 + + +def test_populate_baked_head_commits_backfills_from_disk( + git_enabled_config, db_session, monkeypatch +): + """Build-time baked mirrors should surface a head_commit even with runtime_sync=False.""" + from app.services import git_mirror + + bare = repo_bare_path(git_enabled_config.git.data_dir, "demo-cc") + os.makedirs(bare, exist_ok=True) + with open(os.path.join(bare, "HEAD"), "w") as f: + f.write("ref: refs/heads/main\n") + + monkeypatch.setattr( + git_mirror, "_head_commit_from_disk", lambda path: "feedface" + ) + touched = git_mirror.populate_baked_head_commits(git_enabled_config) + assert touched == 1 + + statuses = git_mirror.list_repo_status(git_enabled_config) + assert any( + s.slug == "demo-cc" and s.head_commit == "feedface" for s in statuses + ) + + +def test_init_db_adds_missing_git_columns(tmp_path, monkeypatch): + """Upgrade path: pre-existing codecollections table gains git_* columns.""" + from sqlalchemy import create_engine, text + + db_url = f"sqlite:///{tmp_path}/upgrade.db" + monkeypatch.setenv("CC_CATALOG_DB_URL", db_url) + from app.config import get_settings + from app import db as db_mod + + get_settings.cache_clear() + db_mod._engine = None + db_mod._SessionLocal = None + + eng = create_engine(db_url, future=True) + with eng.begin() as conn: + conn.execute( + text( + "CREATE TABLE codecollections (" + "id INTEGER PRIMARY KEY, slug VARCHAR(200) UNIQUE NOT NULL)" + ) + ) + eng.dispose() + + db_mod.init_db() + eng2 = create_engine(db_url, future=True) + with eng2.connect() as conn: + cols = {row[1] for row in conn.execute(text("PRAGMA table_info(codecollections)"))} + eng2.dispose() + for col in ("git_head_commit", "git_last_synced", "git_last_sync_error"): + assert col in cols, f"upgrade did not add {col}" diff --git a/cc-catalog-svc/tests/test_sources_oci.py b/cc-catalog-svc/tests/test_sources_oci.py index 9fd8eb806cf..7b464da8fe0 100644 --- a/cc-catalog-svc/tests/test_sources_oci.py +++ b/cc-catalog-svc/tests/test_sources_oci.py @@ -6,6 +6,8 @@ """ from __future__ import annotations +from datetime import datetime, timezone + import httpx import respx @@ -142,3 +144,322 @@ def test_discover_refs_handles_anonymous_bearer_dance(): refs = src.discover_refs(cc) assert len(refs) == 1 assert refs[0].ref == "main" + + +@respx.mock +def test_discover_refs_enriches_built_at_on_tiebreak(): + """When two canonical tags share a ref, fetch built_at to break the tie. + + This is the bug seen in air-gap deployments behind JFrog: the newer + ``main-10792f4-6e4bc81`` push was being beaten by the older + ``main-de76dd0-71dfdc4`` because ``d`` > ``1`` in ASCII. With built_at + enrichment (sourced from manifest config blob `created`), the actually- + newer build wins regardless of tag lex order. + """ + src = OCISource() + repo_path = "stewartshea/rw-cli-codecollection" + cc = { + "slug": "ss-rw-cli-codecollection", + "image_registry": f"jfrog.example.com/{repo_path}", + } + + respx.get(f"https://jfrog.example.com/v2/{repo_path}/tags/list").mock( + return_value=httpx.Response( + 200, + json={ + "name": repo_path, + "tags": [ + "latest", + "main", + "main-10792f4-6e4bc81", # newer push (per config.created) + "main-de76dd0-71dfdc4", # older push, but ASCII-larger + ], + }, + ) + ) + + # Older tag → older config.created. + respx.get(f"https://jfrog.example.com/v2/{repo_path}/manifests/main-de76dd0-71dfdc4").mock( + return_value=httpx.Response(200, json={"config": {"digest": "sha256:old-cfg"}}) + ) + respx.get(f"https://jfrog.example.com/v2/{repo_path}/blobs/sha256:old-cfg").mock( + return_value=httpx.Response(200, json={"created": "2026-05-12T10:00:00Z"}) + ) + + # Newer tag → newer config.created. + respx.get(f"https://jfrog.example.com/v2/{repo_path}/manifests/main-10792f4-6e4bc81").mock( + return_value=httpx.Response(200, json={"config": {"digest": "sha256:new-cfg"}}) + ) + respx.get(f"https://jfrog.example.com/v2/{repo_path}/blobs/sha256:new-cfg").mock( + return_value=httpx.Response(200, json={"created": "2026-05-21T17:00:00Z"}) + ) + + refs = src.discover_refs(cc) + by_tag = {r.image_tag: r for r in refs} + assert by_tag["main-10792f4-6e4bc81"].built_at is not None + assert by_tag["main-de76dd0-71dfdc4"].built_at is not None + assert ( + by_tag["main-10792f4-6e4bc81"].built_at + > by_tag["main-de76dd0-71dfdc4"].built_at + ) + + # The enriched built_at flips resolve_latest's decision from the + # ASCII-largest tag to the actually-newest one. + latest = src.resolve_latest({"default_ref": "main"}, refs) + assert latest == "main-10792f4-6e4bc81" + + +@respx.mock +def test_discover_refs_ignores_misleading_last_modified_from_jfrog(): + """Regression: never trust the Last-Modified header. + + JFrog Artifactory's docker-remote setup proxies an upstream + registry but sets ``Last-Modified`` to JFrog's local cache mtime + — i.e. when JFrog last refreshed the manifest from upstream. After + a cache flush, whichever tag the poll happens to GET first or last + ends up with the freshest cache-mtime regardless of which image + was actually built more recently. Production hit exactly this: + the user cleared JFrog's cache, the catalog re-polled, and the + OLDER ``main-de76dd0-71dfdc4`` ended up with a more recent + ``Last-Modified`` than the NEWER ``main-10792f4-6e4bc81`` (because + cc-catalog-svc happened to GET de76dd0's manifest a few hundred + millis later in the loop). The OCI source must ignore that header + entirely and read ``config.created`` instead. + """ + src = OCISource() + repo_path = "stewartshea/rw-cli-codecollection" + cc = { + "slug": "ss-rw-cli-codecollection", + "image_registry": f"jfrog.example.com/{repo_path}", + } + + respx.get(f"https://jfrog.example.com/v2/{repo_path}/tags/list").mock( + return_value=httpx.Response( + 200, + json={ + "name": repo_path, + "tags": [ + "main-10792f4-6e4bc81", # actually newer + "main-de76dd0-71dfdc4", # actually older + ], + }, + ) + ) + + # The OLDER image, but JFrog cached it most recently → newer LM. + respx.get(f"https://jfrog.example.com/v2/{repo_path}/manifests/main-de76dd0-71dfdc4").mock( + return_value=httpx.Response( + 200, + headers={"Last-Modified": "Thu, 21 May 2026 18:26:19 GMT"}, + json={"config": {"digest": "sha256:older-build-cfg"}}, + ) + ) + respx.get(f"https://jfrog.example.com/v2/{repo_path}/blobs/sha256:older-build-cfg").mock( + return_value=httpx.Response(200, json={"created": "2026-05-12T10:00:00Z"}) + ) + + # The NEWER image, cached slightly earlier → older LM. + respx.get(f"https://jfrog.example.com/v2/{repo_path}/manifests/main-10792f4-6e4bc81").mock( + return_value=httpx.Response( + 200, + headers={"Last-Modified": "Thu, 21 May 2026 18:26:18 GMT"}, + json={"config": {"digest": "sha256:newer-build-cfg"}}, + ) + ) + respx.get(f"https://jfrog.example.com/v2/{repo_path}/blobs/sha256:newer-build-cfg").mock( + return_value=httpx.Response(200, json={"created": "2026-05-21T17:00:00Z"}) + ) + + refs = src.discover_refs(cc) + by_tag = {r.image_tag: r for r in refs} + # build times reflect actual build, NOT Last-Modified + assert by_tag["main-10792f4-6e4bc81"].built_at > by_tag["main-de76dd0-71dfdc4"].built_at + + # And resolve_latest picks the actually-newer build despite the + # Last-Modified misdirection. + latest = src.resolve_latest({"default_ref": "main"}, refs) + assert latest == "main-10792f4-6e4bc81" + + +@respx.mock +def test_discover_refs_skips_enrichment_when_no_tiebreak(): + """With one tag per ref the enrichment must hit zero manifest endpoints. + + Otherwise every poll across many CCs becomes O(tags) extra HTTP calls. + respx in strict mode would raise if we accidentally GET a manifest + here, which would make this test fail. + """ + src = OCISource() + cc = { + "slug": "rw-cli-codecollection", + "image_registry": "ghcr.io/runwhen-contrib/rw-cli-codecollection", + } + + respx.get( + "https://ghcr.io/v2/runwhen-contrib/rw-cli-codecollection/tags/list" + ).mock( + return_value=httpx.Response( + 200, + json={ + "tags": [ + "main-c1a2b3d-e4f5a6b", + "v1.2.0-aabbccd-e4f5a6b", + "pr-42-9988aab-e4f5a6b", + ], + }, + ) + ) + + refs = src.discover_refs(cc) + assert len(refs) == 3 + # built_at stays None for everyone — no enrichment triggered. + assert all(r.built_at is None for r in refs) + + +@respx.mock +def test_discover_refs_falls_back_to_config_blob_created(): + """When Last-Modified is missing (GHCR), descend into manifest -> config blob.""" + src = OCISource() + repo_path = "runwhen-contrib/rw-cli-codecollection" + cc = { + "slug": "rw-cli-codecollection", + "image_registry": f"ghcr.io/{repo_path}", + } + + respx.get(f"https://ghcr.io/v2/{repo_path}/tags/list").mock( + return_value=httpx.Response( + 200, + json={ + "tags": [ + "main-aaaaaaa-bbbbbbb", + "main-1111111-bbbbbbb", + ], + }, + ) + ) + + # Single-platform manifest with config.digest pointing at a blob. + respx.get(f"https://ghcr.io/v2/{repo_path}/manifests/main-aaaaaaa-bbbbbbb").mock( + return_value=httpx.Response( + 200, + json={"config": {"digest": "sha256:older"}}, + ) + ) + respx.get(f"https://ghcr.io/v2/{repo_path}/manifests/main-1111111-bbbbbbb").mock( + return_value=httpx.Response( + 200, + json={"config": {"digest": "sha256:newer"}}, + ) + ) + respx.get(f"https://ghcr.io/v2/{repo_path}/blobs/sha256:older").mock( + return_value=httpx.Response(200, json={"created": "2026-05-12T10:00:00Z"}) + ) + respx.get(f"https://ghcr.io/v2/{repo_path}/blobs/sha256:newer").mock( + return_value=httpx.Response(200, json={"created": "2026-05-21T17:00:00Z"}) + ) + + refs = src.discover_refs(cc) + by_tag = {r.image_tag: r for r in refs} + assert by_tag["main-1111111-bbbbbbb"].built_at == datetime( + 2026, 5, 21, 17, 0, 0, tzinfo=timezone.utc + ) + assert by_tag["main-aaaaaaa-bbbbbbb"].built_at == datetime( + 2026, 5, 12, 10, 0, 0, tzinfo=timezone.utc + ) + + +@respx.mock +def test_discover_refs_handles_manifest_index_for_multiarch(): + """OCI image indices have no top-level config; descend into a child manifest.""" + src = OCISource() + repo_path = "runwhen-contrib/rw-cli-codecollection" + cc = { + "slug": "rw-cli-codecollection", + "image_registry": f"ghcr.io/{repo_path}", + } + + respx.get(f"https://ghcr.io/v2/{repo_path}/tags/list").mock( + return_value=httpx.Response( + 200, + json={ + "tags": [ + "main-aaaaaaa-bbbbbbb", + "main-1111111-bbbbbbb", + ], + }, + ) + ) + + # Both tags are multi-arch indices. + respx.get(f"https://ghcr.io/v2/{repo_path}/manifests/main-aaaaaaa-bbbbbbb").mock( + return_value=httpx.Response( + 200, + json={"manifests": [{"digest": "sha256:older-amd64"}]}, + ) + ) + respx.get(f"https://ghcr.io/v2/{repo_path}/manifests/main-1111111-bbbbbbb").mock( + return_value=httpx.Response( + 200, + json={"manifests": [{"digest": "sha256:newer-amd64"}]}, + ) + ) + # Child platform manifests carry config.digest. + respx.get(f"https://ghcr.io/v2/{repo_path}/manifests/sha256:older-amd64").mock( + return_value=httpx.Response(200, json={"config": {"digest": "sha256:older-cfg"}}) + ) + respx.get(f"https://ghcr.io/v2/{repo_path}/manifests/sha256:newer-amd64").mock( + return_value=httpx.Response(200, json={"config": {"digest": "sha256:newer-cfg"}}) + ) + respx.get(f"https://ghcr.io/v2/{repo_path}/blobs/sha256:older-cfg").mock( + return_value=httpx.Response(200, json={"created": "2026-05-12T10:00:00Z"}) + ) + respx.get(f"https://ghcr.io/v2/{repo_path}/blobs/sha256:newer-cfg").mock( + return_value=httpx.Response(200, json={"created": "2026-05-21T17:00:00Z"}) + ) + + refs = src.discover_refs(cc) + latest = src.resolve_latest({"default_ref": "main"}, refs) + assert latest == "main-1111111-bbbbbbb" + + +@respx.mock +def test_discover_refs_enrichment_tolerates_per_tag_failure(): + """One broken tag's manifest must not poison the entire poll.""" + src = OCISource() + repo_path = "runwhen-contrib/rw-cli-codecollection" + cc = { + "slug": "rw-cli-codecollection", + "image_registry": f"ghcr.io/{repo_path}", + } + + respx.get(f"https://ghcr.io/v2/{repo_path}/tags/list").mock( + return_value=httpx.Response( + 200, + json={ + "tags": [ + "main-aaaaaaa-bbbbbbb", + "main-1111111-bbbbbbb", + ], + }, + ) + ) + # One returns 500 (broken). The other still gets a timestamp. + respx.get(f"https://ghcr.io/v2/{repo_path}/manifests/main-aaaaaaa-bbbbbbb").mock( + return_value=httpx.Response(500) + ) + respx.get(f"https://ghcr.io/v2/{repo_path}/manifests/main-1111111-bbbbbbb").mock( + return_value=httpx.Response(200, json={"config": {"digest": "sha256:ok-cfg"}}) + ) + respx.get(f"https://ghcr.io/v2/{repo_path}/blobs/sha256:ok-cfg").mock( + return_value=httpx.Response(200, json={"created": "2026-05-21T17:00:00Z"}) + ) + + refs = src.discover_refs(cc) + by_tag = {r.image_tag: r for r in refs} + assert by_tag["main-aaaaaaa-bbbbbbb"].built_at is None + assert by_tag["main-1111111-bbbbbbb"].built_at is not None + + # resolve_latest still produces the right answer: the only ref with + # a known built_at outranks the bare-image_tag fallback (epoch_min). + latest = src.resolve_latest({"default_ref": "main"}, refs) + assert latest == "main-1111111-bbbbbbb" diff --git a/cc-registry-v2/backend/app/sources/oci.py b/cc-registry-v2/backend/app/sources/oci.py index 06f5818fdfb..bdef8020234 100644 --- a/cc-registry-v2/backend/app/sources/oci.py +++ b/cc-registry-v2/backend/app/sources/oci.py @@ -14,18 +14,27 @@ v1.2.0-aabbccd-e4f5a6b `latest` resolution: among tags whose ref-portion is `main`, pick the -newest (by manifest `created` if available, otherwise the lexicographically -last — tags are time-monotonic given the sha suffix). +newest by ``built_at`` (manifest creation time fetched lazily — see the +tiebreak enrichment below). Lexicographic on ``image_tag`` only acts as +a last-resort tiebreak when we couldn't get a real timestamp. `stable` resolution: prefer the highest semver-looking ref (`v\\d+...`) if one exists; otherwise fall back to `latest`. +Why the built_at fetch matters: the canonical tag's ``cc_sha7`` prefix is +hex. ``main-1xxxxxx-...`` sorts ASCII-before ``main-dxxxxxx-...`` even +when the ``1xxxxxx`` push happened weeks later. Without an actual +timestamp the catalog would happily keep reporting a stale tag as +``latest`` — and JFrog-fronted catalogs (which cache /v2/.../tags/list) +amplify the staleness window. + NOTE: this source intentionally treats the registry as the source of truth. It never mutates the registry; the cc-registry-v2 catalog is a read-only mirror that powers PAPI lookups. """ from __future__ import annotations +import dataclasses import logging import re from datetime import datetime, timezone @@ -46,6 +55,19 @@ SEMVER_TAG = re.compile(r"^v?\d+\.\d+(\.\d+)?") +# Accept headers we send when fetching an OCI manifest. Order matters: the +# registry returns the first content-type it supports, so we list OCI types +# before Docker ones. Without an explicit Accept the registry MAY return a +# legacy v1 manifest, which has no `config.digest` field we can follow. +_MANIFEST_ACCEPT = ",".join( + [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.docker.distribution.manifest.v2+json", + ] +) + class OCISource(ImageSource): name = "oci" @@ -69,14 +91,31 @@ def discover_refs(self, cc: dict) -> list[DiscoveredImageRef]: return [] host, repo = self._split_registry_url(registry_url) - tags = self._list_tags(host, repo) - - discovered: list[DiscoveredImageRef] = [] - for tag in tags: - ref = self._parse_tag(tag) - if ref is None: - continue - discovered.append(ref) + + # Shared session so the bearer dance, manifest GETs, and + # config-blob fetches reuse the same TCP connection / token. + with requests.Session() as session: + tags = self._list_tags(session, host, repo) + + discovered: list[DiscoveredImageRef] = [] + for tag in tags: + ref = self._parse_tag(tag) + if ref is None: + continue + discovered.append(ref) + + # When multiple canonical tags share a ref (e.g. two builds + # of `main`) we MUST pick the newer one. Lex sort on + # image_tag is wrong: cc_sha7 is hex, so `main-1...` sorts + # before `main-d...` even when the `1...` push happened + # later. Fetch the real build timestamp from the registry + # so the existing (built_at, image_tag) sort picks the + # right tag. We only enrich tags that actually compete — + # single-tag-per-ref polls do zero extra HTTP work. + discovered = self._enrich_built_at_for_tiebreaks( + session, host, repo, discovered + ) + logger.info( "oci source: %s -> %d tags, %d matched build schema", cc.get("slug"), @@ -123,13 +162,18 @@ def _split_registry_url(url: str) -> tuple[str, str]: host, _, repo = url.partition("/") return host, repo - def _list_tags(self, host: str, repo: str) -> list[str]: + def _list_tags( + self, + session: requests.Session, + host: str, + repo: str, + ) -> list[str]: """Walk the v2 tags endpoint with Link-header pagination.""" url = f"https://{host}/v2/{repo}/tags/list" - params = {"n": 200} + params: dict = {"n": 200} all_tags: list[str] = [] for _ in range(self.max_pages): - resp = self._get_with_token(host, repo, url, params) + resp = self._get_with_token(session, host, repo, url, params) resp.raise_for_status() payload = resp.json() all_tags.extend(payload.get("tags") or []) @@ -140,13 +184,28 @@ def _list_tags(self, host: str, repo: str) -> list[str]: url, params = next_url, {} return all_tags - def _get_with_token(self, host: str, repo: str, url: str, params: dict): + def _get_with_token( + self, + session: requests.Session, + host: str, + repo: str, + url: str, + params: dict, + accept: Optional[str] = None, + ): """ Some public registries (GHCR, Docker Hub) require an anonymous bearer token even for public reads. Handle the 401 -> token -> retry dance once. + + Pass ``accept`` to negotiate manifest content-types. The header + is forwarded to the realm-retry leg so the bearer-token request + doesn't return a different manifest type than the first call. """ - resp = requests.get(url, params=params, timeout=self.timeout) + headers: dict[str, str] = {} + if accept: + headers["Accept"] = accept + resp = session.get(url, params=params, timeout=self.timeout, headers=headers) if resp.status_code != 401: return resp @@ -161,17 +220,177 @@ def _get_with_token(self, host: str, repo: str, url: str, params: dict): } if service_match: token_params["service"] = service_match.group(1) - token_resp = requests.get(realm, params=token_params, timeout=self.timeout) + token_resp = session.get(realm, params=token_params, timeout=self.timeout) token_resp.raise_for_status() token = token_resp.json().get("token") or token_resp.json().get("access_token") if not token: return resp - return requests.get( + retry_headers = {"Authorization": f"Bearer {token}"} + if accept: + retry_headers["Accept"] = accept + return session.get( url, params=params, timeout=self.timeout, - headers={"Authorization": f"Bearer {token}"}, + headers=retry_headers, + ) + + # ------------------------------------------------------------------ + # tiebreak enrichment + # ------------------------------------------------------------------ + def _enrich_built_at_for_tiebreaks( + self, + session: requests.Session, + host: str, + repo: str, + refs: list[DiscoveredImageRef], + ) -> list[DiscoveredImageRef]: + """Set ``built_at`` on refs whose ``ref`` is shared by >1 image_tag. + + We only fetch manifests for the ambiguous subset because: + - each enriched tag is up to two registry round-trips, and + - when a ref has exactly one tag there's nothing to tiebreak. + + Failures are best-effort: one broken tag must not poison the + whole sync, so any exception just leaves built_at=None and the + downstream sort falls back to lex-on-image_tag. + """ + by_ref: dict[str, list[DiscoveredImageRef]] = {} + for r in refs: + by_ref.setdefault(r.ref, []).append(r) + ambiguous_tags = { + r.image_tag + for group in by_ref.values() + if len(group) > 1 + for r in group + } + if not ambiguous_tags: + return refs + + built_at_by_tag: dict[str, datetime] = {} + for tag in ambiguous_tags: + built_at = self._fetch_built_at_for_tag(session, host, repo, tag) + if built_at is not None: + built_at_by_tag[tag] = built_at + + if not built_at_by_tag: + return refs + + return [ + dataclasses.replace(r, built_at=built_at_by_tag[r.image_tag]) + if r.image_tag in built_at_by_tag + else r + for r in refs + ] + + def _fetch_built_at_for_tag( + self, + session: requests.Session, + host: str, + repo: str, + tag: str, + ) -> Optional[datetime]: + """Return the build timestamp of an OCI image, best-effort. + + Strategy: GET ``/v2//manifests/`` with Accept headers, + descend into the manifest's ``config.digest`` blob (or, for OCI + image indices, the first child platform manifest's config blob), + and read its ``created`` field. That field is always populated by + buildkit / docker buildx and is the only OCI-spec'd source of + truth for when the image was actually built. + + We deliberately do NOT use the ``Last-Modified`` HTTP header, + even though many registries set it. Caching proxies — most + notably JFrog Artifactory's docker-remote setup — set + ``Last-Modified`` to the local CACHE freshness time, not the + upstream build time. That means whichever tag the sync happens + to GET first or last would win the tiebreak based on + cache-warmup order, completely unrelated to which image is + actually newer. The OCI spec does not require ``Last-Modified`` + either, so taking the extra HTTP hop to ``config.digest`` is the + only universally correct path. + + Returns None on any failure so the caller can fall back to its + lex-only ordering rather than crash the sync. + """ + manifest_url = f"https://{host}/v2/{repo}/manifests/{tag}" + try: + resp = self._get_with_token( + session, host, repo, manifest_url, params={}, + accept=_MANIFEST_ACCEPT, + ) + if resp.status_code != 200: + logger.debug( + "oci source: manifest GET %s:%s returned %s", + repo, + tag, + resp.status_code, + ) + return None + + manifest = resp.json() + return self._fetch_created_from_manifest( + session, host, repo, manifest + ) + except Exception: + logger.debug( + "oci source: failed to fetch built_at for %s:%s", + repo, + tag, + exc_info=True, + ) + return None + + def _fetch_created_from_manifest( + self, + session: requests.Session, + host: str, + repo: str, + manifest: dict, + ) -> Optional[datetime]: + """Resolve a manifest doc to its config-blob ``created`` timestamp. + + Handles both single-platform manifests (``config.digest`` is on + the top-level) and image indices / manifest lists (descend into + the first child manifest — all platforms of a multi-arch build + share the same buildkit timestamp). + """ + config_digest: Optional[str] = None + child_manifests = manifest.get("manifests") + if child_manifests: + child_digest = (child_manifests[0] or {}).get("digest") + if not child_digest: + return None + child_url = f"https://{host}/v2/{repo}/manifests/{child_digest}" + child_resp = self._get_with_token( + session, host, repo, child_url, params={}, + accept=_MANIFEST_ACCEPT, + ) + if child_resp.status_code != 200: + return None + config_digest = (child_resp.json().get("config") or {}).get("digest") + else: + config_digest = (manifest.get("config") or {}).get("digest") + + if not config_digest: + return None + + blob_url = f"https://{host}/v2/{repo}/blobs/{config_digest}" + blob_resp = self._get_with_token( + session, host, repo, blob_url, params={} ) + if blob_resp.status_code != 200: + return None + created = blob_resp.json().get("created") + if not created: + return None + # OCI uses RFC 3339; normalize "Z" for fromisoformat (<3.11). + if created.endswith("Z"): + created = created[:-1] + "+00:00" + try: + return datetime.fromisoformat(created) + except ValueError: + return None @staticmethod def _parse_next_link(link_header: str, host: str) -> Optional[str]: diff --git a/cc-registry-v2/backend/app/tasks/image_sync_tasks.py b/cc-registry-v2/backend/app/tasks/image_sync_tasks.py index c5314fd1f85..76775178080 100644 --- a/cc-registry-v2/backend/app/tasks/image_sync_tasks.py +++ b/cc-registry-v2/backend/app/tasks/image_sync_tasks.py @@ -21,7 +21,7 @@ import logging import os -from datetime import datetime +from datetime import datetime, timezone from typing import Optional import yaml @@ -162,14 +162,43 @@ def _upsert_versions( # Group by ref name. Multiple discovered tags can share a single git ref # (e.g. several builds of `main`). The row schema keys on version_name, - # so we have to pick one. Choose the lexicographically-largest image_tag - # so the kept entry matches what OCISource.resolve_latest selects (it - # uses the same ordering); without this, is_latest never matches the - # row that wins the dict and the /resolve?pointer=latest path 404s. + # so we have to pick one. Match OCISource.resolve_latest's ordering + # exactly: prefer the highest ``built_at`` (manifest creation time as + # populated by the source's tiebreak enrichment), falling back to a + # lex sort on ``image_tag`` when timestamps are missing or tied. + # + # Earlier versions used the lex sort alone — that's wrong for our + # canonical ``--`` schema because cc_sha7 is hex + # and has no temporal ordering (``main-1...`` sorts before ``main-d...`` + # even if the ``1...`` build is newer). With ``built_at`` populated, the + # surviving row tracks what ``resolve_latest`` declared so ``is_latest`` + # always lands on the right row and ``/resolve?pointer=latest`` returns + # the freshest build. + _EPOCH_MIN = datetime.min.replace(tzinfo=timezone.utc) + + def _ensure_aware(ts): + # Sources return tz-aware datetimes (HTTP Last-Modified is RFC 7231, + # OCI manifests use RFC 3339), but custom sources may return naive + # values. Mixing the two in a comparison raises TypeError; treating + # naive as UTC is consistent with how `now = datetime.utcnow()` + # writes timestamps elsewhere in this module. + if ts is None: + return None + if ts.tzinfo is None: + return ts.replace(tzinfo=timezone.utc) + return ts + + def _newer(a, b) -> bool: + a_ts = _ensure_aware(getattr(a, "built_at", None)) or _EPOCH_MIN + b_ts = _ensure_aware(getattr(b, "built_at", None)) or _EPOCH_MIN + if a_ts != b_ts: + return a_ts > b_ts + return (a.image_tag or "") > (b.image_tag or "") + refs_by_name: dict[str, DiscoveredImageRef] = {} for r in refs: existing_choice = refs_by_name.get(r.ref) - if existing_choice is None or (r.image_tag or "") > (existing_choice.image_tag or ""): + if existing_choice is None or _newer(r, existing_choice): refs_by_name[r.ref] = r existing_versions = (