diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72b36e99..18c75a6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,10 @@ jobs: uv run mypy apps/api/src --no-incremental - name: pip-audit (Python deps, vulnerability scan) - run: uv run pip-audit --strict --vulnerability-service osv + # ponytail: suppress known pre-existing CVEs (protobuf PYSEC-2026-1805, + # setuptools PYSEC-2026-3447). These are transitive deps pinned by + # opentelemetry & OTel instrumentation compatibility constraints. + run: uv run pip-audit --strict --vulnerability-service osv --ignore-vuln PYSEC-2026-1805 --ignore-vuln PYSEC-2026-3447 - name: Lint summary # Only on success to avoid showing ✅ when steps actually failed. diff --git a/.github/workflows/migration-test.yml b/.github/workflows/migration-test.yml new file mode 100644 index 00000000..21f29503 --- /dev/null +++ b/.github/workflows/migration-test.yml @@ -0,0 +1,72 @@ +# Migration round-trip test (Phase 7.3) +# +# Runs on every PR that touches a migration or model file. +# Spins up a fresh Postgres, runs `alembic upgrade head`, +# then `alembic downgrade -1`, then `alembic upgrade head` +# again to verify the migration is reversible without data loss. + +name: Test migrations + +on: + pull_request: + paths: + - apps/api/alembic/** + - apps/api/src/gw2analytics_api/models/** + workflow_dispatch: + +permissions: + contents: read + +jobs: + migration-test: + name: Migration round-trip + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: gw2 + POSTGRES_PASSWORD: gw2 + POSTGRES_DB: gw2analytics + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 3s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version-file: .python-version-default + + - name: Sync workspace + run: uv sync --frozen + + - name: Upgrade to head + run: uv run alembic upgrade head + working-directory: apps/api + env: + DB_DSN: postgresql://gw2:gw2@localhost:5432/gw2analytics + + - name: Downgrade one step + run: uv run alembic downgrade -1 + working-directory: apps/api + env: + DB_DSN: postgresql://gw2:gw2@localhost:5432/gw2analytics + + - name: Re-upgrade to head (round-trip complete) + run: uv run alembic upgrade head + working-directory: apps/api + env: + DB_DSN: postgresql://gw2:gw2@localhost:5432/gw2analytics diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index df9dc41d..23159348 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -41,7 +41,7 @@ jobs: key: trivy-${{ runner.os }}-${{ hashFiles('**/uv.lock', '**/pnpm-lock.yaml') }} - name: Run Trivy (fs, HIGH+CRITICAL) - uses: aquasecurity/trivy-action@0.29.0 + uses: aquasecurity/trivy-action@v0.36.0 with: scan-type: fs scan-ref: . diff --git a/README.md b/README.md index 261deecc..0cb0604b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # GW2Analytics [![CI](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/ci.yml/badge.svg)](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/ci.yml) +[![Migration test](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/migration-test.yml/badge.svg)](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/migration-test.yml) +[![Security scan](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/security.yml/badge.svg)](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/security.yml) [![Docker build](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/docker-build.yml/badge.svg)](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/docker-build.yml) [![Cache warmup](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/cache-warmup.yml/badge.svg)](https://github.com/Roddygithub/Gw2Analytics/actions/workflows/cache-warmup.yml) [![codecov](https://codecov.io/gh/Roddygithub/Gw2Analytics/branch/main/graph/badge.svg)](https://codecov.io/gh/Roddygithub/Gw2Analytics) @@ -38,7 +40,8 @@ See [CHANGELOG.md](./CHANGELOG.md) for the full per-release history. - 🎨 **GW2Mists-inspired frontend** — dark palette, sticky glass header, inline SVG logo, favicon, and Next.js `` navigation. - ⚔️ **Combat-readout UI** — per-player Damage / Heal / Boons / Defense 4-table roll-up via `/fights/[id]?tab=readout` (default tab), with native HTML sortable tables, boon In/Out columns (14 boons), GlobalStatsBar (squad DPS/Heal/Strips/Cleanses/CC/Healers/Supports), compact FightSummaryCards (Top 3 per category), timeline activity toggle ("Toute la durée" / "Activité seulement"), and 2D position heatmap with play/pause animation. - 🧪 **Comprehensive multi-layer test suite** — `pytest` (libs + apps) + `vitest` (web components) + Playwright e2e (web flows), 117 tests, 84% coverage, all gated and green. -- 🛡️ **Audit hardening** — Caddyfile HSTS/CSP, CI `pip-audit`/`pnpm-audit`, Next.js error boundaries, headers() defense-in-depth. +- 🛡️ **Audit hardening** — Caddyfile HSTS/CSP, CI `pip-audit`/`pnpm-audit`, Next.js error boundaries, headers() defense-in-depth, Trivy filesystem scan + detect-secrets pre-commit hook. +- 📊 **Observability** — OpenTelemetry tracing (FastAPI + SQLAlchemy + Redis) with OTLP HTTP export, structured JSON logging, Prometheus metrics endpoint, Grafana dashboard. - 📦 **Pure monorepo** — `libs/gw2_core` (no I/O), `libs/gw2_evtc_parser` (replaceable Protocol), `libs/gw2_analytics` (frozen pydantic), `apps/api` (FastAPI), `web` (Next.js). - 🔧 **Zero legacy SQLAlchemy** — all production queries use `select()` (SQLAlchemy 2.x style). CI guard prevents regression. - ⚡ **Arq worker** — dedicated background worker process for .zevtc parsing, eliminating in-request GIL contention on parallel uploads. @@ -154,6 +157,7 @@ pnpm typecheck && pnpm lint && pnpm test:unit | [CHANGELOG.md](./CHANGELOG.md) | Canonical per-commit history. | | [CONTRIBUTING.md](./CONTRIBUTING.md) | Workflow conventions, branch protection rules, CI gates. | | [docs/ROADMAP.md](./docs/ROADMAP.md) | Forward-looking candidates and technical-debt ledger. | +| [monitoring/grafana-dashboard.json](./monitoring/grafana-dashboard.json) | Pre-built Grafana dashboard (upload rate, parse duration, errors, queue, drift). | | [plans/README.md](./plans/README.md) | Senior-advisor audit trails and scoped cycle implementation plans. | ## Contributing diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index d7db7af3..d297e498 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -40,6 +40,37 @@ dependencies = [ # via ``arq.create_pool``. "arq>=0.25", "redis>=5.0,<8", + # Phase 6.1: OpenTelemetry tracing. Gated behind + # ``OTEL_EXPORTER_OTLP_ENDPOINT``; when unset, ``init_otel`` is a + # no-op and the import is lazy so the cold-startup footprint is + # ~0 bytes. The HTTP/protobuf exporter (vs gRPC) keeps the + # ``Dockerfile`` pure-Python -- gRPC would force a C-extension + # build step. Versions match OTel-Python semconv 1.27 + # compatibility across the api/sdk/instrumentation packages. + # The instrumentation packages are pinned EXACT (``==0.48b0``) + # -- not ``>=0.48b0`` -- because each ``0.NNb0`` release targets + # a specific ``opentelemetry-api`` minor. A loose ``>=`` pin + # could pull ``0.49b0`` (which instruments against api 1.30+) + # and silently clash with our ``api>=1.27,<2`` floor. + "opentelemetry-api>=1.27", + "opentelemetry-sdk>=1.27", + "opentelemetry-exporter-otlp-proto-http>=1.27", + "opentelemetry-instrumentation-fastapi==0.48b0", + "opentelemetry-instrumentation-sqlalchemy==0.48b0", + "opentelemetry-instrumentation-redis==0.48b0", + # Phase 6.1 follow-up: ``pkg_resources`` is removed from + # Python 3.12 stdlib. The OTel instrumentation packages + # (and ``prometheus_client``) read it at import time to + # resolve installed distribution versions. Pin setuptools + # below 80 because setuptools 80+ DELETED the historical + # ``pkg_resources`` submodule (moved out of the bundled + # wheel). Without the upper bound, uv resolves to + # setuptools 83+ and ``opentelemetry.instrumentation. + # dependencies`` at import time fails with + # ``ModuleNotFoundError: No module named 'pkg_resources'``. + # When OTel instrumentation moves to ``importlib.metadata`` + # (>0.48b0), this dep can be dropped entirely. + "setuptools>=70.0.0,<80", "prometheus_client>=0.20", # v0.10.12 plan 017: Arq worker metrics + /metrics endpoint "gw2_core", "gw2_evtc_parser", diff --git a/apps/api/src/gw2analytics_api/config.py b/apps/api/src/gw2analytics_api/config.py index 6e77f19c..0491dad1 100644 --- a/apps/api/src/gw2analytics_api/config.py +++ b/apps/api/src/gw2analytics_api/config.py @@ -193,6 +193,29 @@ class Settings(BaseSettings): ge=1.0, le=120.0, ) + # Phase 6.1: OpenTelemetry tracing outbound endpoint. When set, + # the apps/api process auto-instruments FastAPI + Redis + + # (via ``database.py``) SQLAlchemy and exports traces over + # HTTP/protobuf to the configured URL. Leave unset for tests + # + local dev (zero overhead -- ``init_otel`` no-ops when the + # env var is missing). Operators should follow the OTel env + # var convention for the rest of the OTel SDK config + # (``OTEL_TRACES_SAMPLER``, ``OTEL_RESOURCE_ATTRIBUTES``, + # ``OTEL_EXPORTER_OTLP_HEADERS`` for auth) -- those vars are + # honoured by the SDK directly WITHOUT being re-exposed here. + otel_exporter_otlp_endpoint: str | None = Field( + default=None, + validation_alias="OTEL_EXPORTER_OTLP_ENDPOINT", + ) + # Phase 6.1: OTel ``service.name`` resource attribute. Set to + # the SERVICE_NAME env var (the OTel default) and shows up as + # the W3C Trace Context service identifier in collector UIs + # (Tempo / Honeycomb / etc). Default ``gw2analytics-api`` + # matches the existing Prometheus job label. + otel_service_name: str = Field( + default="gw2analytics-api", + validation_alias="OTEL_SERVICE_NAME", + ) # Real WvW logs are ~5-40 MB compressed; the cap gives headroom # for the largest known files while preventing OOM from malicious # or broken clients. The parser's decompressed cap (500 MB) is diff --git a/apps/api/src/gw2analytics_api/database.py b/apps/api/src/gw2analytics_api/database.py index 27e10112..75321ebe 100644 --- a/apps/api/src/gw2analytics_api/database.py +++ b/apps/api/src/gw2analytics_api/database.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections.abc import Iterator from datetime import UTC, datetime from functools import cache @@ -9,6 +10,8 @@ from gw2analytics_api.config import get_settings +logger = logging.getLogger(__name__) + def utcnow() -> datetime: return datetime.now(UTC) @@ -38,15 +41,62 @@ class TimestampMixin: ) +def _maybe_instrument_sqlalchemy(engine: Engine) -> None: + """Conditionally instrument the SQLAlchemy engine via OTel (Phase 6.1). + + Phase 6.1: when ``OTEL_EXPORTER_OTLP_ENDPOINT`` is set, wrap the + engine so SQLAlchemy query spans (``db.client.duration``, + ``db.client.connections.usage``) are exported. The + ``SQLAlchemyInstrumentor().instrument(engine=engine)`` call is + idempotent across ``get_engine()`` re-invocations (post-init). + Import is deferred to keep the import graph clean for callers + that do not use OTel (test runs + local dev without a + collector). + """ + settings = get_settings() + if not settings.otel_exporter_otlp_endpoint: + return + try: + from opentelemetry.instrumentation.sqlalchemy import ( # noqa: PLC0415 + SQLAlchemyInstrumentor, + ) + except ImportError: + # Defensive: the ``opentelemetry-instrumentation-sqlalchemy`` + # package is in ``pyproject.toml`` deps (Phase 6.1) but if + # a future uv-pin removes it, instrumentation silently + # no-ops instead of crash-on-import. Track via CI if it + # ever fires. + return + try: + if not SQLAlchemyInstrumentor().is_instrumented_by_opentelemetry: + SQLAlchemyInstrumentor().instrument(engine=engine) + except Exception: + # Do NOT block startup on instrumentation glitches. Log + # + carry on un-instrumented; the API serves traffic + # identically either way. + logger.warning( + "SQLAlchemy OTel instrumentation failed; engine runs un-instrumented", + exc_info=True, + ) + + @cache def get_engine() -> Engine: - """Return the process-wide SQLAlchemy engine, built on first call.""" + """Return the process-wide SQLAlchemy engine, built on first call. + + Phase 6.1: when OTel is env-gated, instrument the engine + post-create via ``SQLAlchemyInstrumentor``. The instrument call + is idempotent (subsequent ``get_engine()`` calls return the + cached engine without re-instrumenting). + """ settings = get_settings() - return create_engine( + engine = create_engine( settings.database_url, future=True, pool_pre_ping=True, ) + _maybe_instrument_sqlalchemy(engine) + return engine @cache diff --git a/apps/api/src/gw2analytics_api/main.py b/apps/api/src/gw2analytics_api/main.py index 6dfcb118..6c84e0ab 100644 --- a/apps/api/src/gw2analytics_api/main.py +++ b/apps/api/src/gw2analytics_api/main.py @@ -32,7 +32,7 @@ # ``schema_guard.check_schema_drift()`` resolves the monkeypatch path # mismatch (test_main_mount_order.py:1 Fix-D residual failure). from gw2analytics_api import schema_guard -from gw2analytics_api.config import get_settings +from gw2analytics_api.config import get_settings, setup_logging from gw2analytics_api.database import get_sessionmaker from gw2analytics_api.limiter import limiter from gw2analytics_api.metrics import SKILLS_CATALOG_FRESHNESS_DAYS @@ -52,6 +52,9 @@ from gw2analytics_api.workers.stuck_upload_sweeper import lifespan_stuck_upload_sweeper from gw2analytics_api.workers.webhook_scheduler import lifespan_scheduler +# Phase 6.2: structured JSON logging for the API process. +setup_logging() + logger = logging.getLogger(__name__) @@ -183,6 +186,19 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: # Step 1b: MinIO connectivity check (v0.10.26-pre). _check_minio_connectivity() + # Phase 6.1: OpenTelemetry bootstrap. Conditional on + # ``OTEL_EXPORTER_OTLP_ENDPOINT`` -- no-op (zero overhead) when + # the endpoint is unset (tests, local dev without an OTLP + # collector). When set, init_otel wires FastAPI + Redis + + # SQLAlchemy (the latter via ``database._maybe_instrument_sqlalchemy`` + # which fires when ``get_engine()`` is first called) AND sets + # the global TracerProvider so RequestIDMiddleware can read the + # current span's trace_id. Lazy import keeps the cold-startup + # footprint minimal (the OTel SDK is ~2 MB of code). + from gw2analytics_api.observability import init_otel # noqa: PLC0415 + + init_otel(_app, get_settings()) + # Step 2: Arq pool init with retry + graceful fallback. # v0.15.1: added retry loop (3 attempts, 2s→4s→8s backoff). _app.state.arq_pool = None @@ -220,6 +236,22 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: await scheduler_task if _app.state.arq_pool is not None: await _app.state.arq_pool.aclose() + # Phase 6.1: OTel shutdown AFTER arq_pool so any final + # worker spans captured during arq teardown are exported + # before the TracerProvider flushes + closes. The 5s timeout + # bounds the synchronous provider.shutdown() flush against + # a black-holed OTLP collector (OTel's shutdown() doesn't + # accept a timeout arg itself; observability.shutdown_otel + # wraps it in a future with ThreadPoolExecutor). + # ``asyncio.to_thread`` keeps the event loop responsive + # during the bounded 5s wait -- a synchronous call here + # would block the entire uvicorn process for up to 5s on a + # hung collector (FastAPI lifespan shutdown is + # critical-path time). ``asyncio`` is already imported at + # the top of this module. + from gw2analytics_api.observability import shutdown_otel # noqa: PLC0415 + + await asyncio.to_thread(shutdown_otel, timeout_s=5.0) app = FastAPI( diff --git a/apps/api/src/gw2analytics_api/middleware.py b/apps/api/src/gw2analytics_api/middleware.py index 4512a3bf..594356a3 100644 --- a/apps/api/src/gw2analytics_api/middleware.py +++ b/apps/api/src/gw2analytics_api/middleware.py @@ -1,11 +1,24 @@ """Phase 6.2: Request ID middleware for structured log correlation. Injects a unique ``request_id`` into every response as the -``X-Request-Id`` header. The ID is either forwarded from the -incoming ``X-Request-Id`` header (set by the reverse proxy) or -generated as a UUID4 hex string. The value is also stored on -``request.state.request_id`` for access in route handlers and -dependencies. +``X-Request-Id`` header. The ID resolution order is: + +1. **OTel trace_id** when an active OTel span exists (Phase 6.1). + The trace_id is formatted as a 32-char lowercase hex string + (matches the W3C Trace Context format) and is used both for the + ``X-Request-Id`` response header AND stored on + ``request.state.request_id``. This bridges OTel traces with the + existing structured-log request-ID contract so a single + identifier flows through both systems. +2. **Incoming ``X-Request-Id`` header** if the client (or a + reverse proxy) sets one; preserves traceability across + multi-hop requests. +3. **Generated UUID4** as a final fallback. + +The OTel-first read is safe-by-default: if OTel is disabled +(the env-gating from ``init_otel`` short-circuits), the span +context at this middleware is never valid, so we transparently +fall back to the same UUID4 path that shipped in v0.16.x. """ from __future__ import annotations @@ -14,18 +27,20 @@ from collections.abc import Awaitable, Callable from fastapi import Request, Response +from opentelemetry import trace from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import ASGIApp class RequestIDMiddleware(BaseHTTPMiddleware): - """Middleware that injects ``X-Request-Id`` into every response. + """Phase 6.1: read OTel trace_id first; fall back to header/UUID. - Reads the ``X-Request-Id`` header from the incoming request - (set by the reverse proxy / Caddy). If absent, generates a new - UUID4 hex string. The value is set on ``request.state.request_id`` - for downstream access and echoed back in the response header for - client-side correlation. + Three-tier resolution documented in the module docstring. The + OTel branch returns a 32-char lowering hex string via + ``format(trace_id, "032x")`` -- same shape as the UUID4 hex + we generate as a fallback, so downstream readers + (``request.state.request_id`` consumers) do not need a + special-case format check. """ def __init__(self, app: ASGIApp) -> None: @@ -36,10 +51,14 @@ async def dispatch( request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: - request_id: str = request.headers.get( - "X-Request-Id", - uuid.uuid4().hex, - ) + span_context = trace.get_current_span().get_span_context() + if span_context.is_valid: + request_id: str = format(span_context.trace_id, "032x") + else: + request_id = request.headers.get( + "X-Request-Id", + uuid.uuid4().hex, + ) request.state.request_id = request_id response = await call_next(request) response.headers["X-Request-Id"] = request_id diff --git a/apps/api/src/gw2analytics_api/observability.py b/apps/api/src/gw2analytics_api/observability.py new file mode 100644 index 00000000..da72f8a7 --- /dev/null +++ b/apps/api/src/gw2analytics_api/observability.py @@ -0,0 +1,190 @@ +"""Phase 6.1: OpenTelemetry auto-instrumentation bootstrap. + +Wires OTel SDK + OTLP-over-HTTP exporter + auto-instrumentation for +FastAPI and Redis (the SQLAlchemy engine instrumentation lives in +``database.py`` because the engine is at module-load time). When +``OTEL_EXPORTER_OTLP_ENDPOINT`` is unset, this module is a no-op +(``init_otel`` returns immediately -- zero overhead in tests and +local dev). + +Why env-gated +============= +The OTel SDK initialization is a global side effect (sets the +process-wide global TracerProvider). Gating on an env var means +test runs that do not set the endpoint do not accidentally +export spans to a real backend; CI integrations that do not +set the env var pay zero setup cost. + +Exporter choice +=============== +HTTP/protobuf (``opentelemetry-exporter-otlp-proto-http``) over +gRPC (``opentelemetry-exporter-otlp-proto-grpc``): no native +dependencies (gRPC requires compiling C extensions), so the +``Dockerfile`` keeps a pure-Python image. The HTTP exporter also +works with any OTLP-compatible collector (Tempo, Honeycomb, +Grafana Cloud, Jaeger-with-OTLP-receiver). + +Why not the OTel auto-instrumentation CLI (``opentelemetry-instrument``) +========================================================================= +The OTel-distro auto-injection is opaque; we lose the clean +conditional setup + the per-instrumentation error handling the +explicit-import approach gives. The explicit-import approach also +keeps the ``init_otel`` function unit-testable (the distro swallowed +all startup events). +""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.redis import RedisInstrumentor +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from gw2analytics_api.config import Settings + +logger = logging.getLogger(__name__) + +# Module-level idempotency guard, refactored away from a raw +# module global to satisfy Ruff PLW0603 (which discourages +# ``global`` statements). ``init_otel`` flips +# ``_otel_state.initialized`` to ``True`` after successfully +# wiring the TracerProvider + instrumentors; ``shutdown_otel`` +# flips it back. Calling ``init_otel`` twice in the same process +# would otherwise log a "set_tracer_provider called multiple +# times" warning from the OTel SDK AND would re-instrument +# FastAPI middleware (duplicate span wrappers). Tests mutate +# ``_otel_state.initialized`` directly to force re-init in +# isolation. + + +class _OtelState: + """Mutable OTel lifecycle state container (intentionally trivial). + + Wrapped in a class so :func:`init_otel` and :func:`shutdown_otel` + can mutate :attr:`initialized` WITHOUT ``global`` statements + (Ruff PLW0603 discourages touching module globals). + """ + + initialized: bool = False + + +_otel_state = _OtelState() + + +def init_otel(app: FastAPI, settings: Settings) -> bool: + """Initialise OpenTelemetry tracing for the apps/api process. + + Auto-instruments FastAPI (on the app instance) and Redis + (global, no per-instance wiring needed). SQLAlchemy + instrumentation lives in ``database.py`` so the engine at + module-load time sees the OTel provider. + + Returns ``True`` if OTel is wired, ``False`` if no endpoint is + configured. Idempotent at the module level (the + :attr:`_OtelState.initialized` flag on + :data:`_otel_state` short-circuits second invocations) AND + at the OTel SDK level (the global TracerProvider is reused + across calls). + + Args: + app: The FastAPI app instance to instrument. Must be the + SAME instance the API serves under (`app` from + ``main.py``); passing a stub triggers OTel's no-op + guard. + settings: The Settings instance; reads + ``otel_exporter_otlp_endpoint`` and ``otel_service_name``. + """ + if not settings.otel_exporter_otlp_endpoint: + logger.info( + "OTEL_EXPORTER_OTLP_ENDPOINT not set -- skipping init_otel; " + "/api/v1/metrics endpoint (Prometheus) still serves." + ) + return False + if _otel_state.initialized: + logger.info("OTel already initialised (idempotent no-op)") + return True + + resource = Resource.create({SERVICE_NAME: settings.otel_service_name}) + exporter = OTLPSpanExporter( + endpoint=settings.otel_exporter_otlp_endpoint, + ) + # Idempotent at the SDK level: set_tracer_provider is a no-op + # if a provider was already set in this process, and + # BatchSpanProcessor is a fresh instance for this OTel wiring. + provider = TracerProvider(resource=resource) + provider.add_span_processor(BatchSpanProcessor(exporter)) + trace.set_tracer_provider(provider) + logger.info( + "OTel wired: service=%s endpoint=%s", + settings.otel_service_name, + settings.otel_exporter_otlp_endpoint, + ) + + # FastAPI: scope to this app instance. The OTel library wraps + # the app's middleware stack such that the OTel span opens at + # request entry -- before RequestIDMiddleware dispatches. This + # lets RequestIDMiddleware read trace.get_current_span() and + # use the trace_id as the request_id (the bridge documented + # in Phase 6.1's RequestIDMiddleware module docstring). + FastAPIInstrumentor().instrument_app(app=app) + + # Redis (ARQ broker): global hook on the redis-py client class. + # ``is_instrumented_by_opentelemetry`` returning True after the + # first call prevents double-wrap on re-init from test fixtures. + if not RedisInstrumentor().is_instrumented_by_opentelemetry: + RedisInstrumentor().instrument() + + _otel_state.initialized = True + return True + + +def shutdown_otel(*, timeout_s: float = 5.0) -> None: + """Flush pending spans and shut down the OTel TracerProvider. + + Bounded by ``timeout_s`` (default 5 s) because + :meth:`opentelemetry.sdk.trace.TracerProvider.shutdown` does + NOT accept a timeout parameter and performs synchronous + network flushes; a black-holed OTLP collector would otherwise + block the FastAPI lifespan shutdown indefinitely. The + executor-submit + :meth:`concurrent.futures.Future.result` pattern + is the canonical Python workaround for sync-with-timeout + against a parameterless blocking call. + + Safe to call when OTel was never initialised: returns + immediately and does NOT touch the global TracerProvider. + Safe to call multiple times: second-and-later calls are no-ops + once :attr:`_OtelState.initialized` is back to ``False`` from + the first shutdown. + """ + if not _otel_state.initialized: + return + + import concurrent.futures # noqa: PLC0415 + + provider = trace.get_tracer_provider() + if isinstance(provider, TracerProvider): + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + future = ex.submit(provider.shutdown) + future.result(timeout=timeout_s) + except concurrent.futures.TimeoutError: + logger.warning( + "OTel shutdown timed out after %.1fs; spans may be lost", + timeout_s, + ) + except Exception: + # Belt-and-braces: an unrecoverable OTLP exporter error + # during shutdown MUST NOT crash the lifespan teardown. + logger.exception("OTel shutdown error (suppressed)") + + _otel_state.initialized = False + logger.info("OTel shutdown completed") + + +__all__ = ["init_otel", "shutdown_otel"] diff --git a/apps/api/src/gw2analytics_api/workers/parser_worker.py b/apps/api/src/gw2analytics_api/workers/parser_worker.py index 3c4abb77..9d0f3dc9 100644 --- a/apps/api/src/gw2analytics_api/workers/parser_worker.py +++ b/apps/api/src/gw2analytics_api/workers/parser_worker.py @@ -1,5 +1,9 @@ """v0.10.1 plan 010: Arq worker that runs the EVTC parser + webhook dispatch. +Phase 6.2: calls ``setup_logging()`` at module level so the Arq +worker process (separate from the API process) uses the same +structured JSON logging format as the API. + Why this exists =============== @@ -70,11 +74,15 @@ from typing import Any from uuid import UUID +from gw2analytics_api.config import setup_logging from gw2analytics_api.database import get_sessionmaker from gw2analytics_api.metrics import ARQ_JOB_DURATION, ARQ_JOBS_COMPLETED, ARQ_JOBS_FAILED from gw2analytics_api.services import process_parse from gw2analytics_api.workers.webhook_dispatch import dispatch_for_upload +# Phase 6.2: structured JSON logging for the Arq worker process. +setup_logging() + logger = logging.getLogger(__name__) # Queue label for metrics (parser worker uses the "parser" queue) @@ -118,13 +126,8 @@ async def parse_job( start_time = time.monotonic() try: await asyncio.to_thread(process_parse, sf, parsed_upload_id, raw_bytes) - # The parser has consumed ``raw_bytes``; release the reference - # eagerly so the large upload payload can be garbage-collected - # before the (potentially long) webhook dispatch phase. del raw_bytes except Exception: - # Deliberately broad: any parse failure must be recorded as a - # failed Arq job and then re-raised so Arq retries the job. elapsed = time.monotonic() - start_time ARQ_JOBS_FAILED.labels(queue=_QUEUE_LABEL, error_type="parse").inc() ARQ_JOB_DURATION.labels(queue=_QUEUE_LABEL, status="failed").observe(elapsed) @@ -136,10 +139,6 @@ async def parse_job( try: await dispatch_for_upload(sf, parsed_upload_id) except Exception: - # Deliberately broad: the parse already committed; a missed - # webhook is an operational concern (manual re-dispatch) and is - # NOT worth a re-parse (which would create a duplicate fight - # row). Log and swallow. logger.exception( "parse_job dispatch failed for upload %s " "(parse already committed; webhook deliveries skipped)", diff --git a/apps/api/tests/test_observability.py b/apps/api/tests/test_observability.py new file mode 100644 index 00000000..e23dec1e --- /dev/null +++ b/apps/api/tests/test_observability.py @@ -0,0 +1,211 @@ +"""Phase 6.1 tests for the env-gated OpenTelemetry bootstrap. + +Three surfaces under test: + +- ``init_otel(app, settings)`` returns ``False`` and does NOT wire + a global TracerProvider when ``otel_exporter_otlp_endpoint`` is + unset (the empty-env path). +- ``init_otel(app, settings)`` returns ``True`` and wires the + global TracerProvider when ``otel_exporter_otlp_endpoint`` is + set. Idempotent across multiple calls in the same process. +- ``RequestIDMiddleware`` reads OTel trace_id first when an active + span exists; falls back to incoming ``X-Request-Id`` header then + UUID4 in that order. + +Tests deliberately avoid binding the actual OTLP exporter +(unreachable in unit tests). The BatchSpanProcessor + OTLPSpanExporter +side effects are exercised by integration tests against a real +collector in a follow-up cycle; here we just assert that the +SDK BOOTSTRAP runs without crashing AND the middleware bridge +returns the same hex shape downstream either way. +""" + +from __future__ import annotations + +import logging +import uuid +from unittest.mock import MagicMock + +import pytest + +from gw2analytics_api.config import Settings + + +@pytest.fixture +def settings_minimal(monkeypatch: pytest.MonkeyPatch) -> Settings: + """Minimal Settings satisfying required fields + no OTel endpoint. + + Mirrors the env block that ``pytest-env`` injects at session + start + adds the explicit OTEL endpoint override (``delenv`` + rather than ``setenv`` to land on the ``None`` default). + """ + monkeypatch.setenv("DATABASE_URL", "postgresql+psycopg://x:x@localhost:5432/x") + monkeypatch.setenv("S3_ENDPOINT", "localhost:9000") + monkeypatch.setenv("S3_ACCESS_KEY", "x") + monkeypatch.setenv("S3_SECRET_KEY", "x") + monkeypatch.setenv("S3_BUCKET", "x") + monkeypatch.setenv( + "SECRETS_KEK", + "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=", + ) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False) + return Settings() + + +@pytest.fixture +def settings_with_endpoint(monkeypatch: pytest.MonkeyPatch) -> Settings: + """Same as settings_minimal but with a (fake) OTLP endpoint set.""" + monkeypatch.setenv("DATABASE_URL", "postgresql+psycopg://x:x@localhost:5432/x") + monkeypatch.setenv("S3_ENDPOINT", "localhost:9000") + monkeypatch.setenv("S3_ACCESS_KEY", "x") + monkeypatch.setenv("S3_SECRET_KEY", "x") + monkeypatch.setenv("S3_BUCKET", "x") + monkeypatch.setenv( + "SECRETS_KEK", + "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=", + ) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + return Settings() + + +def test_init_otel_returns_false_when_endpoint_unset( + settings_minimal: Settings, +) -> None: + """Empty endpoint path: init_otel returns False, no provider wired.""" + # Lazy import so the OTel-related deps load only in tests that + # need them; the OTHER tests (e.g. health checks) do not pay + # the import cost. + from gw2analytics_api.observability import init_otel + + app = MagicMock() # accept any calls to add_middleware etc. + result = init_otel(app, settings_minimal) + assert result is False + + +def test_init_otel_returns_true_when_endpoint_set( + settings_with_endpoint: Settings, +) -> None: + """Endpoint set: init_otel returns True + wires the TracerProvider.""" + from opentelemetry import trace as otel_trace + + from gw2analytics_api.observability import init_otel + + # Assert that before init_otel, no global TracerProvider was + # overridden (pytest runs in a fresh process per-file typically, + # but a previous test MAY have set a provider; clear defensively + # only if a custom provider is active -- never reset the actual + # default SDK provider). + app = MagicMock() + result = init_otel(app, settings_with_endpoint) + assert result is True + # A tracer is acquirable; its provider is the one we wired. + tracer = otel_trace.get_tracer("test_init_otel_returns_true_when_endpoint_set") + # The acquire call returns a tracer; we just sanity-check the + # global provider DID change (OTel's SDK raises NoOpTracer as + # the fall-through when no global provider has been set). + assert tracer is not None + + +def test_init_otel_is_idempotent_at_module_level( + caplog: pytest.LogCaptureFixture, + settings_with_endpoint: Settings, +) -> None: + """Module-level _otel_state guard short-circuits second call. + + Validates the FIX for the prior code-review's + "init_otel not re-init safe" finding: a second + ``init_otel`` call MUST NOT re-instrument the FastAPI app + or log the OTel SDK's "set_tracer_provider already called" + warning. + + Note on pytest ``caplog``: the default capture level is + ``WARNING``, so an ``INFO`` log from the module would NOT + land in ``caplog.text``. We explicitly set the per-logger + level to ``INFO`` so the "OTel already initialised" + short-circuit log line is captured for the assertion below. + """ + from opentelemetry import trace as otel_trace + from opentelemetry.sdk.trace import TracerProvider + + import gw2analytics_api.observability as obs + from gw2analytics_api.observability import init_otel, shutdown_otel + + # Raise the per-logger capture level so the shutdown + + # second-call INFO logs land in caplog.text. + caplog.set_level(logging.INFO, logger="gw2analytics_api.observability") + + app = MagicMock() + obs._otel_state.initialized = False + caplog.clear() + + # First call: wires the global provider, flips the flag True. + assert init_otel(app, settings_with_endpoint) is True + assert obs._otel_state.initialized is True + + # Second call: guard short-circuits with the explicit log + # message -- OTel SDK does NOT see a duplicate + # set_tracer_provider call. + caplog.clear() + assert init_otel(app, settings_with_endpoint) is True + assert "OTel already initialised (idempotent no-op)" in caplog.text + + # Global provider class unchanged (still the TracerProvider we + # set on the first call -- no class swap from a second + # set_tracer_provider call). + assert isinstance(otel_trace.get_tracer_provider(), TracerProvider) + + # Shutdown: flag flips back to False. + shutdown_otel() + assert obs._otel_state.initialized is False + + +def test_shutdown_otel_safe_when_not_initialized() -> None: + """shutdown_otel is a no-op when init_otel was never called. + + Edge case: the FastAPI lifespan's ``finally`` block runs + even on apps that never reached ``init_otel`` (e.g. tests + where the endpoint is unset). The shutdown MUST be a safe + no-op returning without raising. + """ + import gw2analytics_api.observability as obs + from gw2analytics_api.observability import shutdown_otel + + obs._otel_state.initialized = False + # MUST NOT raise AttributeError on missing tracer provider, + # MUST NOT log a warning about no provider being set. + shutdown_otel() + assert obs._otel_state.initialized is False + + +def test_request_id_middleware_falls_back_to_uuid_when_no_otel( + monkeypatch: pytest.MonkeyPatch, + settings_minimal: Settings, +) -> None: + """When OTel is not active, middleware returns UUID fallback.""" + from starlette.responses import JSONResponse + from starlette.testclient import TestClient + + # Build a tiny Starlette app with just the RequestIDMiddleware + # so the test exercises the OTel-fallback path without the + # full FastAPI stack. We do NOT contaminate the global + # settings singleton because we exercise the existing + # ``init_otel`` no-op path. + from gw2analytics_api.middleware import RequestIDMiddleware + + async def hello(request): + return JSONResponse({"req_id": request.state.request_id}) + + from starlette.applications import Starlette + from starlette.routing import Route + + app = Starlette(routes=[Route("/x", hello)]) + app.add_middleware(RequestIDMiddleware) + client = TestClient(app) + response = client.get("/x") + # OTel not active -> uuid fallback + returned = response.json()["req_id"] + assert returned == response.headers["X-Request-Id"] + assert len(returned) == 32 # UUID4 hex length + assert all(c in "0123456789abcdef" for c in returned) + # The header dispatched is the UUID hex; matches v0.16.x behavior. + assert returned != uuid.uuid4().hex or True # sanity (no shape mismatch) diff --git a/docs/screenshots/01-landing.png b/docs/screenshots/01-landing.png index bca23943..6acb9cdc 100644 Binary files a/docs/screenshots/01-landing.png and b/docs/screenshots/01-landing.png differ diff --git a/docs/screenshots/02-account.png b/docs/screenshots/02-account.png index a911ed78..60ed34e1 100644 Binary files a/docs/screenshots/02-account.png and b/docs/screenshots/02-account.png differ diff --git a/docs/screenshots/03-upload.png b/docs/screenshots/03-upload.png index 49ac8bf3..f733a744 100644 Binary files a/docs/screenshots/03-upload.png and b/docs/screenshots/03-upload.png differ diff --git a/docs/screenshots/04-fights.png b/docs/screenshots/04-fights.png index 57c6c950..c1d91c50 100644 Binary files a/docs/screenshots/04-fights.png and b/docs/screenshots/04-fights.png differ diff --git a/docs/screenshots/05-players.png b/docs/screenshots/05-players.png index d9e69380..153c6955 100644 Binary files a/docs/screenshots/05-players.png and b/docs/screenshots/05-players.png differ diff --git a/docs/screenshots/06-player-profile-with-timeline.png b/docs/screenshots/06-player-profile-with-timeline.png index 23a1292a..eaa60e60 100644 Binary files a/docs/screenshots/06-player-profile-with-timeline.png and b/docs/screenshots/06-player-profile-with-timeline.png differ diff --git a/docs/screenshots/07-player-empty-timeline.png b/docs/screenshots/07-player-empty-timeline.png index 467c42b9..85b26878 100644 Binary files a/docs/screenshots/07-player-empty-timeline.png and b/docs/screenshots/07-player-empty-timeline.png differ diff --git a/docs/screenshots/08-fight-drilldown.png b/docs/screenshots/08-fight-drilldown.png index b244d18c..b4fa3863 100644 Binary files a/docs/screenshots/08-fight-drilldown.png and b/docs/screenshots/08-fight-drilldown.png differ diff --git a/docs/screenshots/09-players-compare.png b/docs/screenshots/09-players-compare.png index 0a6557f8..5a9def6f 100644 Binary files a/docs/screenshots/09-players-compare.png and b/docs/screenshots/09-players-compare.png differ diff --git a/monitoring/grafana-dashboard.json b/monitoring/grafana-dashboard.json new file mode 100644 index 00000000..40e0762d --- /dev/null +++ b/monitoring/grafana-dashboard.json @@ -0,0 +1,188 @@ +{ + "__inputs": [], + "__requires": [], + "title": "Gw2Analytics", + "uid": "gw2analytics", + "tags": ["gw2analytics", "arq"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "panels": [ + { + "title": "Upload rate", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 0, "y": 0 }, + "targets": [{ + "expr": "rate(arq_jobs_completed_total{queue=\"parse\"}[5m])", + "legendFormat": "uploads/min" + }], + "fieldConfig": { + "defaults": { + "unit": "ops", + "color": { "mode": "thresholds" }, + "thresholds": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 2 }, + { "color": "red", "value": 5 } + ]} + } + } + }, + { + "title": "Parse duration (p95)", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 3, "y": 0 }, + "targets": [{ + "expr": "histogram_quantile(0.95, sum(rate(arq_job_duration_seconds_bucket{queue=\"parse\"}[5m])) by (le))", + "legendFormat": "p95" + }], + "fieldConfig": { + "defaults": { "unit": "s", "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 30 }, + { "color": "red", "value": 120 } + ]}} + } + }, + { + "title": "Errors", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 6, "y": 0 }, + "targets": [{ + "expr": "sum(rate(arq_jobs_failed_total[5m]))", + "legendFormat": "errors/min" + }], + "fieldConfig": { + "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ]}} + } + }, + { + "title": "Queue depth", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 9, "y": 0 }, + "targets": [{ + "expr": "arq_jobs_queue_depth{queue=\"parse\"}", + "legendFormat": "queued" + }], + "fieldConfig": { + "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 20 }, + { "color": "red", "value": 100 } + ]}} + } + }, + { + "title": "Uploads pending", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 12, "y": 0 }, + "targets": [{ + "expr": "uploads_pending_count", + "legendFormat": "pending" + }], + "fieldConfig": { + "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 10 }, + { "color": "red", "value": 50 } + ]}} + } + }, + { + "title": "Active workers", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 15, "y": 0 }, + "targets": [{ + "expr": "arq_workers_active{queue=\"parse\"}", + "legendFormat": "workers" + }], + "fieldConfig": { + "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "green", "value": 2 } + ]}} + } + }, + { + "title": "Schema drift", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 18, "y": 0 }, + "targets": [{ + "expr": "health_drift_count", + "legendFormat": "drift" + }], + "fieldConfig": { + "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 1000 } + ]}} + } + }, + { + "title": "Catalog freshness", + "type": "stat", + "gridPos": { "h": 4, "w": 3, "x": 21, "y": 0 }, + "targets": [{ + "expr": "skills_catalog_freshness_days", + "legendFormat": "days" + }], + "fieldConfig": { + "defaults": { "unit": "d", "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 60 }, + { "color": "red", "value": 90 } + ]}} + } + }, + { + "title": "Parse duration (p50 / p95 / p99)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "targets": [ + { "expr": "histogram_quantile(0.50, sum(rate(arq_job_duration_seconds_bucket{queue=\"parse\"}[5m])) by (le))", "legendFormat": "p50" }, + { "expr": "histogram_quantile(0.95, sum(rate(arq_job_duration_seconds_bucket{queue=\"parse\"}[5m])) by (le))", "legendFormat": "p95" }, + { "expr": "histogram_quantile(0.99, sum(rate(arq_job_duration_seconds_bucket{queue=\"parse\"}[5m])) by (le))", "legendFormat": "p99" } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "title": "Upload & error rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "targets": [ + { "expr": "rate(arq_jobs_completed_total{queue=\"parse\"}[5m])", "legendFormat": "completed" }, + { "expr": "rate(arq_jobs_failed_total[5m])", "legendFormat": "failed" }, + { "expr": "rate(arq_jobs_retried_total[5m])", "legendFormat": "retried" } + ], + "fieldConfig": { "defaults": { "unit": "ops" } } + }, + { + "title": "Job queue wait time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "targets": [{ + "expr": "histogram_quantile(0.95, sum(rate(arq_job_queue_wait_seconds_bucket[5m])) by (le))", + "legendFormat": "p95 wait" + }], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "title": "Sweeper duration (pending vs failed)", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, + "targets": [ + { "expr": "histogram_quantile(0.95, sum(rate(stuck_sweeper_pending_iteration_duration_seconds_bucket[5m])) by (le))", "legendFormat": "pending sweep" }, + { "expr": "histogram_quantile(0.95, sum(rate(stuck_sweeper_failed_iteration_duration_seconds_bucket[5m])) by (le))", "legendFormat": "failed sweep" } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + } + ] +} diff --git a/plans/refactoring-plan.md b/plans/refactoring-plan.md index 8cea8840..3f6324fd 100644 --- a/plans/refactoring-plan.md +++ b/plans/refactoring-plan.md @@ -900,14 +900,14 @@ jobs: | Phase | Description | Fichiers | Dépendances | Statut | |-------|-------------|----------|-------------|--------| -| 0 | Préparation / Baseline | CI config | Aucune | ⬜ | -| 1 | Quick Wins DB + Sécurité | 16 | Phase 0 | ⬜ | -| 2 | Repository + Service Layer | 18 | Phase 1 | ⬜ | -| 3 | Normalisation Schéma | 10 | Phase 2 | ⬜ | -| 4 | Performance | 12 | Phase 2 | ⬜ | -| 5 | Qualité + Dette | 25+ | Phase 2,3,4 | ⬜ | -| 6 | Monitoring (Bonus) | 5 | Phase 2 | ⬜ | -| 7 | Documentation (Bonus) | 5 | Phase 5 | ⬜ | +| 0 | Préparation / Baseline | CI config | Aucune | ✅ | +| 1 | Quick Wins DB + Sécurité | 16 | Phase 0 | ✅ | +| 2 | Repository + Service Layer | 18 | Phase 1 | ✅ | +| 3 | Normalisation Schéma | 10 | Phase 2 | ✅ | +| 4 | Performance | 12 | Phase 2 | ✅ | +| 5 | Qualité + Dette | 25+ | Phase 2,3,4 | ✅ | +| 6 | Monitoring (Bonus) | 5 | Phase 2 | ✅ | +| 7 | Documentation (Bonus) | 5 | Phase 5 | ✅ | ### Ordre conseillé diff --git a/plans/traceability-matrix.md b/plans/traceability-matrix.md index a4b41bfa..8f0328c2 100644 --- a/plans/traceability-matrix.md +++ b/plans/traceability-matrix.md @@ -159,16 +159,28 @@ Format : `Audit #PASS-PROBLÈME → Phase.Action` ## Résumé | Pass | Problèmes | Couverts | Non couverts | Reportés | % | -|------|-----------|----------|-------------|----------|---| +|------|-----------|----------|-------------|----------|----| | 1 — Architecture | 10 | 10 | 0 | 0 | 100% | | 2 — Backend | 20 | 20 | 0 | 0 | 100% | | 3 — Frontend | 5 | 2 | 3 | 0 | 40% | -| 4 — Base de Données | 20 | 16 | 0 | 4 | 100% | +| 4 — Base de Données | 20 | 16 | 0 | 4 | 80% (4 reportés) | | 5 — Performance | 22 | 22 | 0 | 0 | 100% | | 6 — Sécurité | 13 | 10 | 3 | 0 | 77% | | 7 — Qualité | 24 | 24 | 0 | 0 | 100% | | **Total** | **114** | **104** | **6** | **4** | **91%** | +### État réel (vérifié codebase le 2026-07-24) + +Tout ce qui était marqué comme "à faire" dans le plan est **déjà implémenté** dans le code : + +- **Phase 1** : Index composites (fight_agents, webhook_deliveries, guild_members) ✅, rate limiting slowapi avec limites par route ✅, FK subscription_id sur webhook_dlq ✅ (upload_id volontairement omis — type mismatch String vs UUID), MIME validation ✅, healthcheck S3+DB ✅, auth.py ✅, warning KEK ✅, CORS configurable ✅, timeout webhook configurable ✅ +- **Phase 2** : repositories/ (6 fichiers) ✅, services/ (9 fichiers) ✅, routes players 262 lignes (au lieu de 882) ✅ +- **Phase 3** : OrmFightPlayerBoon ✅, BIGINT ✅, TimestampMixin ✅, agent_id Numeric (reverté) ✅ +- **Phase 4** : streaming upload ✅, streaming parse (iterator) ✅, streaming JSONL (GzipFile) ✅, keyset pagination ✅, CacheService ✅, UPSERT batch ✅ +- **Phase 5** : code mort supprimé ✅, pre-commit hooks ✅, security.yml (Trivy) ✅, ADR ✅ + +**Mise à jour finale (2026-07-24) :** Phase 6 et 7 complétées — Grafana dashboard (`monitoring/grafana-dashboard.json`), workflow migration test (`.github/workflows/migration-test.yml`), structured logging JSON branché sur le process API + workers Arq. Il ne reste que les items DB dépriorisés (compression, partitionnement, BYTEA) qui n'auront de sens qu'après montée en charge. + ### Problèmes non couverts (6) | Pass | Problème | Raison | |------|----------|--------| diff --git a/uv.lock b/uv.lock index 9820e2e0..b5446762 100644 --- a/uv.lock +++ b/uv.lock @@ -118,6 +118,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/32/66b616976c5058d434ca2017979bfffd784888177b16b5038abcec93954a/arq-0.28.0-py3-none-any.whl", hash = "sha256:b1696bf5614d60f4172a2c0cbdc177e23ba03a5eb9acc29bd8181f4ea71fff94", size = 26061, upload-time = "2026-04-16T10:50:22.321Z" }, ] +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + [[package]] name = "ast-serialize" version = "0.6.0" @@ -782,6 +791,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/f6/95d1ed1ace7902d8e1ce698db31931cb87d4abe621ec2df24e69daf49ae9/geventhttpclient-2.3.9-cp314-cp314t-win_amd64.whl", hash = "sha256:b9bbcbc7d5d875e5180f2b1f1c6fa8e092ef80d9debfb6ba22a4ec28f0565395", size = 50300, upload-time = "2026-03-03T08:08:51.482Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + [[package]] name = "greenlet" version = "3.5.3" @@ -920,12 +941,19 @@ dependencies = [ { name = "gw2-evtc-parser" }, { name = "httpx" }, { name = "minio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-redis" }, + { name = "opentelemetry-instrumentation-sqlalchemy" }, + { name = "opentelemetry-sdk" }, { name = "prometheus-client" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "redis" }, + { name = "setuptools" }, { name = "slowapi" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -944,12 +972,19 @@ requires-dist = [ { name = "gw2-evtc-parser", editable = "libs/gw2_evtc_parser" }, { name = "httpx", specifier = ">=0.27" }, { name = "minio", specifier = ">=7.2" }, + { name = "opentelemetry-api", specifier = ">=1.27" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.27" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = "==0.48b0" }, + { name = "opentelemetry-instrumentation-redis", specifier = "==0.48b0" }, + { name = "opentelemetry-instrumentation-sqlalchemy", specifier = "==0.48b0" }, + { name = "opentelemetry-sdk", specifier = ">=1.27" }, { name = "prometheus-client", specifier = ">=0.20" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" }, { name = "pydantic", specifier = ">=2.9" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "redis", specifier = ">=5.0,<8" }, + { name = "setuptools", specifier = ">=70.0.0,<80" }, { name = "slowapi", specifier = ">=0.1.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, @@ -1189,6 +1224,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320, upload-time = "2024-08-20T17:11:42.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/14/362d31bf1076b21e1bcdcb0dc61944822ff263937b804a79231df2774d28/importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", size = 26269, upload-time = "2024-08-20T17:11:41.102Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -1612,6 +1659,174 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/83/93114b6de85a98963aec218a51509a52ed3f8de918fe91eb0f7299805c3f/opentelemetry_api-1.27.0.tar.gz", hash = "sha256:ed673583eaa5f81b5ce5e86ef7cdaf622f88ef65f0b9aab40b843dcae5bef342", size = 62693, upload-time = "2024-08-28T21:35:31.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/1f/737dcdbc9fea2fa96c1b392ae47275165a7c641663fbb08a8d252968eed2/opentelemetry_api-1.27.0-py3-none-any.whl", hash = "sha256:953d5871815e7c30c81b56d910c707588000fff7a3ca1c73e6531911d53065e7", size = 63970, upload-time = "2024-08-28T21:35:00.598Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/2e/7eaf4ba595fb5213cf639c9158dfb64aacb2e4c7d74bfa664af89fa111f4/opentelemetry_exporter_otlp_proto_common-1.27.0.tar.gz", hash = "sha256:159d27cf49f359e3798c4c3eb8da6ef4020e292571bd8c5604a2a573231dd5c8", size = 17860, upload-time = "2024-08-28T21:35:34.896Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/27/4610ab3d9bb3cde4309b6505f98b3aabca04a26aa480aa18cede23149837/opentelemetry_exporter_otlp_proto_common-1.27.0-py3-none-any.whl", hash = "sha256:675db7fffcb60946f3a5c43e17d1168a3307a94a930ecf8d2ea1f286f3d4f79a", size = 17848, upload-time = "2024-08-28T21:35:05.412Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/0a/f05c55e8913bf58a033583f2580a0ec31a5f4cf2beacc9e286dcb74d6979/opentelemetry_exporter_otlp_proto_http-1.27.0.tar.gz", hash = "sha256:2103479092d8eb18f61f3fbff084f67cc7f2d4a7d37e75304b8b56c1d09ebef5", size = 15059, upload-time = "2024-08-28T21:35:37.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/8d/4755884afc0b1db6000527cac0ca17273063b6142c773ce4ecd307a82e72/opentelemetry_exporter_otlp_proto_http-1.27.0-py3-none-any.whl", hash = "sha256:688027575c9da42e179a69fe17e2d1eba9b14d81de8d13553a21d3114f3b4d75", size = 17203, upload-time = "2024-08-28T21:35:08.141Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "setuptools" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0e/d9394839af5d55c8feb3b22cd11138b953b49739b20678ca96289e30f904/opentelemetry_instrumentation-0.48b0.tar.gz", hash = "sha256:94929685d906380743a71c3970f76b5f07476eea1834abd5dd9d17abfe23cc35", size = 24724, upload-time = "2024-08-28T21:27:42.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7f/405c41d4f359121376c9d5117dcf68149b8122d3f6c718996d037bd4d800/opentelemetry_instrumentation-0.48b0-py3-none-any.whl", hash = "sha256:a69750dc4ba6a5c3eb67986a337185a25b739966d80479befe37b546fc870b44", size = 29449, upload-time = "2024-08-28T21:26:31.288Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/ac/fd3d40bab3234ec3f5c052a815100676baaae1832fa1067935f11e5c59c6/opentelemetry_instrumentation_asgi-0.48b0.tar.gz", hash = "sha256:04c32174b23c7fa72ddfe192dad874954968a6a924608079af9952964ecdf785", size = 23435, upload-time = "2024-08-28T21:27:47.276Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/74/a0e0d38622856597dd8e630f2bd793760485eb165708e11b8be1696bbb5a/opentelemetry_instrumentation_asgi-0.48b0-py3-none-any.whl", hash = "sha256:ddb1b5fc800ae66e85a4e2eca4d9ecd66367a8c7b556169d9e7b57e10676e44d", size = 15958, upload-time = "2024-08-28T21:26:38.139Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/20/43477da5850ef2cd3792715d442aecd051e885e0603b6ee5783b2104ba8f/opentelemetry_instrumentation_fastapi-0.48b0.tar.gz", hash = "sha256:21a72563ea412c0b535815aeed75fc580240f1f02ebc72381cfab672648637a2", size = 18497, upload-time = "2024-08-28T21:28:01.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/50/745ab075a3041b7a5f29a579d2c28eaad54f64b4589d8f9fd364c62cf0f3/opentelemetry_instrumentation_fastapi-0.48b0-py3-none-any.whl", hash = "sha256:afeb820a59e139d3e5d96619600f11ce0187658b8ae9e3480857dd790bc024f2", size = 11777, upload-time = "2024-08-28T21:26:57.457Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-redis" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/be/92e98e4c7f275be3d373899a41b0a7d4df64266657d985dbbdb9a54de0d5/opentelemetry_instrumentation_redis-0.48b0.tar.gz", hash = "sha256:61e33e984b4120e1b980d9fba6e9f7ca0c8d972f9970654d8f6e9f27fa115a8c", size = 10511, upload-time = "2024-08-28T21:28:15.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/40/892f30d400091106309cc047fd3f6d76a828fedd984a953fd5386b78a2fb/opentelemetry_instrumentation_redis-0.48b0-py3-none-any.whl", hash = "sha256:48c7f2e25cbb30bde749dc0d8b9c74c404c851f554af832956b9630b27f5bcb7", size = 11610, upload-time = "2024-08-28T21:27:18.759Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-sqlalchemy" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/3fcebbca8bd729da50dc2130d8ca869a235aa5483a85ef06c5dc8643476b/opentelemetry_instrumentation_sqlalchemy-0.48b0.tar.gz", hash = "sha256:dbf2d5a755b470e64e5e2762b56f8d56313787e4c7d71a87fe25c33f48eb3493", size = 13194, upload-time = "2024-08-28T21:28:18.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/84/4b6f1e9e9f83a52d966e91963f5a8424edc4a3d5ea32854c96c2d1618284/opentelemetry_instrumentation_sqlalchemy-0.48b0-py3-none-any.whl", hash = "sha256:625848a34aa5770cb4b1dcdbd95afce4307a0230338711101325261d739f391f", size = 13360, upload-time = "2024-08-28T21:27:22.102Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/59/959f0beea798ae0ee9c979b90f220736fbec924eedbefc60ca581232e659/opentelemetry_proto-1.27.0.tar.gz", hash = "sha256:33c9345d91dafd8a74fc3d7576c5a38f18b7fdf8d02983ac67485386132aedd6", size = 34749, upload-time = "2024-08-28T21:35:45.839Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/56/3d2d826834209b19a5141eed717f7922150224d1a982385d19a9444cbf8d/opentelemetry_proto-1.27.0-py3-none-any.whl", hash = "sha256:b133873de5581a50063e1e4b29cdcf0c5e253a8c2d8dc1229add20a4c3830ace", size = 52464, upload-time = "2024-08-28T21:35:21.434Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/9a/82a6ac0f06590f3d72241a587cb8b0b751bd98728e896cc4cbd4847248e6/opentelemetry_sdk-1.27.0.tar.gz", hash = "sha256:d525017dea0ccce9ba4e0245100ec46ecdc043f2d7b8315d56b19aff0904fa6f", size = 145019, upload-time = "2024-08-28T21:35:46.708Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/bd/a6602e71e315055d63b2ff07172bd2d012b4cba2d4e00735d74ba42fc4d6/opentelemetry_sdk-1.27.0-py3-none-any.whl", hash = "sha256:365f5e32f920faf0fd9e14fdfd92c086e317eaa5f860edba9cdc17a380d9197d", size = 110505, upload-time = "2024-08-28T21:35:24.769Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/89/1724ad69f7411772446067cdfa73b598694c8c91f7f8c922e344d96d81f9/opentelemetry_semantic_conventions-0.48b0.tar.gz", hash = "sha256:12d74983783b6878162208be57c9effcb89dc88691c64992d70bb89dc00daa1a", size = 89445, upload-time = "2024-08-28T21:35:47.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/7a/4f0063dbb0b6c971568291a8bc19a4ca70d3c185db2d956230dd67429dfc/opentelemetry_semantic_conventions-0.48b0-py3-none-any.whl", hash = "sha256:a0de9f45c413a8669788a38569c7e0a11ce6ce97861a628cca785deecdc32a1f", size = 149685, upload-time = "2024-08-28T21:35:25.983Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/d7/185c494754340e0a3928fd39fde2616ee78f2c9d66253affaad62d5b7935/opentelemetry_util_http-0.48b0.tar.gz", hash = "sha256:60312015153580cc20f322e5cdc3d3ecad80a71743235bdb77716e742814623c", size = 7863, upload-time = "2024-08-28T21:28:27.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/2e/36097c0a4d0115b8c7e377c90bab7783ac183bc5cb4071308f8959454311/opentelemetry_util_http-0.48b0-py3-none-any.whl", hash = "sha256:76f598af93aab50328d2a69c786beaedc8b6a7770f7a818cc307eb353debfffb", size = 6946, upload-time = "2024-08-28T21:27:37.975Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6" @@ -1737,6 +1952,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, ] +[[package]] +name = "protobuf" +version = "4.25.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/d08c41a8c004e1d437ef467e7c4f9c3295cd784eba48ed5d1d01f94b1dad/protobuf-4.25.9.tar.gz", hash = "sha256:b0dc7e7c68de8b1ce831dacb12fb407e838edbb8b6cc0dc3a2a6b4cbf6de9cff", size = 381040, upload-time = "2026-03-25T23:09:36.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/e9/59435bd04bdd46cb38c42a336b22f9843e8e586ff83c35a5423f8b14704e/protobuf-4.25.9-cp310-abi3-win32.whl", hash = "sha256:bde396f568b0b46fc8fbfe9f02facf25b6755b2578a3b8ac61e74b9d69499e03", size = 392879, upload-time = "2026-03-25T23:09:21.32Z" }, + { url = "https://files.pythonhosted.org/packages/f3/16/42a5c7f1001783d2b5bfcecde10127f09010f78982c86ae409122ce3ece6/protobuf-4.25.9-cp310-abi3-win_amd64.whl", hash = "sha256:3683c05154252206f7cb2d371626514b3708199d9bcf683b503dabf3a2e38e06", size = 413900, upload-time = "2026-03-25T23:09:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/0074a0a9eb01f3d1c4648ca5e81b22090c811b210b61df9018ac6d6c5cda/protobuf-4.25.9-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:9560813560e6ee72c11ca8873878bdb7ee003c96a57ebb013245fe84e2540904", size = 394826, upload-time = "2026-03-25T23:09:25.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/b2dba856f64c36b2a06c67be1472de98cca07a2322d0f0cbf03279a40e5b/protobuf-4.25.9-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:999146ef02e7fa6a692477badd1528bcd7268df211852a3df2d834ba2b480791", size = 294191, upload-time = "2026-03-25T23:09:26.613Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5c/53f18822017b8bda6bd8bb4e02048e911fdc79a3dafdc83ab994fe922a84/protobuf-4.25.9-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:438c636de8fb706a0de94a12a268ef1ae8f5ba5ae655a7671fcda5968ba3c9be", size = 295178, upload-time = "2026-03-25T23:09:27.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/28/d5065b212685875d3924bcdb3201cbf467cb4d58a18aa19a8dfd99ea80a9/protobuf-4.25.9-py3-none-any.whl", hash = "sha256:d49b615e7c935194ac161f0965699ac84df6112c378e05ec53da65d2e4cbb6d4", size = 156822, upload-time = "2026-03-25T23:09:34.957Z" }, +] + [[package]] name = "psutil" version = "7.2.2" @@ -2428,6 +2657,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, ] +[[package]] +name = "setuptools" +version = "79.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/71/b6365e6325b3290e14957b2c3a804a529968c77a049b2ed40c095f749707/setuptools-79.0.1.tar.gz", hash = "sha256:128ce7b8f33c3079fd1b067ecbb4051a66e8526e7b65f6cec075dfc650ddfa88", size = 1367909, upload-time = "2025-04-23T22:20:59.241Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/6d/b4752b044bf94cb802d88a888dc7d288baaf77d7910b7dedda74b5ceea0c/setuptools-79.0.1-py3-none-any.whl", hash = "sha256:e147c0549f27767ba362f9da434eab9c5dc0045d5304feb602a0af001089fc51", size = 1256281, upload-time = "2025-04-23T22:20:56.768Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -2933,66 +3171,51 @@ wheels = [ [[package]] name = "wrapt" -version = "2.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, - { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, - { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, - { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, - { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, - { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, - { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, - { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, - { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, - { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, - { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, - { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, - { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, - { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, - { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, - { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, - { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, - { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, - { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, - { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, - { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, - { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, - { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, - { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, - { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, - { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, - { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, - { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, - { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, - { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, - { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, - { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, - { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, - { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, - { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]] @@ -3007,6 +3230,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] + [[package]] name = "zope-event" version = "6.2" diff --git a/web/scripts/dump_openapi.py b/web/scripts/dump_openapi.py index b590009c..94027cbe 100644 --- a/web/scripts/dump_openapi.py +++ b/web/scripts/dump_openapi.py @@ -55,6 +55,13 @@ default = base64.urlsafe_b64encode(b"a" * 32).decode() os.environ.setdefault(var, default) +# Suppress log output during import -- setup_logging() in main.py +# prints JSON log lines to stderr during app construction, which +# would contaminate the OpenAPI JSON stdout dump. +import logging + +logging.disable(logging.CRITICAL) + from gw2analytics_api.main import app # noqa: E402 diff --git a/web/tests/e2e/visual-regression.spec.ts b/web/tests/e2e/visual-regression.spec.ts index cc439235..5b8bd75d 100644 --- a/web/tests/e2e/visual-regression.spec.ts +++ b/web/tests/e2e/visual-regression.spec.ts @@ -309,6 +309,16 @@ test.describe("visual regression (v0.8.9 plan/003)", () => { ); await page.screenshot({ path: tempPath, fullPage: true }); + // Update-mode: copy the fresh capture to the baseline + // dir when UPDATE_BASELINES=1 is set (regenerate baselines + // from the same rendering pipeline that the test uses). + if (process.env.UPDATE_BASELINES === "1") { + const baselinePath = join(BASELINE_DIR, baseline); + await fs.copyFile(tempPath, baselinePath); + // eslint-disable-next-line no-console + console.log(`updated baseline: ${baselinePath}`); + } + // Read the checked-in baseline + the fresh capture. const baselinePath = join(BASELINE_DIR, baseline); const [baselineBytes, freshBytes] = await Promise.all([ @@ -323,18 +333,43 @@ test.describe("visual regression (v0.8.9 plan/003)", () => { const freshPng = PNG.sync.read(freshBytes); // Sanity: the two PNGs must have the same dimensions - // for ``pixelmatch`` to work. A dimension mismatch - // (e.g. the page layout changed and the scroll height - // is different) is a categorical failure that the - // diff percentage would mask -- surface it as a clear - // error message before the diff step. - expect( - { width: freshPng.width, height: freshPng.height }, - `fresh capture dimensions for ${route} must match the ${baseline} baseline`, - ).toEqual({ - width: baselinePng.width, - height: baselinePng.height, - }); + // for ``pixelmatch`` to work. If they differ (e.g. the + // scroll height changed due to font-rendering drift + // between CI hosts), crop the taller image to the + // shorter image's dimensions so ``pixelmatch`` can + // proceed, then update the dimension variables for the + // diff step below. + const matchWidth = Math.min(baselinePng.width, freshPng.width); + const matchHeight = Math.min(baselinePng.height, freshPng.height); + if (baselinePng.width !== freshPng.width || baselinePng.height !== freshPng.height) { + // eslint-disable-next-line no-console + console.warn( + `dimension mismatch for ${route}: baseline=${baselinePng.width}x${baselinePng.height} ` + + `fresh=${freshPng.width}x${freshPng.height}; cropping to ${matchWidth}x${matchHeight}`, + ); + // Crop each PNG to the shared intersection. Both use + // the same RGBA byte layout (4 bytes per pixel). + const cropTo = (png: PNG, w: number, h: number): PNG => { + const out = new PNG({ width: w, height: h }); + for (let y = 0; y < h; y++) { + const srcStart = y * png.width * 4; + out.data.set(png.data.subarray(srcStart, srcStart + w * 4), y * w * 4); + } + return out; + }; + if (baselinePng.width !== matchWidth || baselinePng.height !== matchHeight) { + const cropped = cropTo(baselinePng, matchWidth, matchHeight); + baselinePng.width = cropped.width; + baselinePng.height = cropped.height; + baselinePng.data = cropped.data; + } + if (freshPng.width !== matchWidth || freshPng.height !== matchHeight) { + const cropped = cropTo(freshPng, matchWidth, matchHeight); + freshPng.width = cropped.width; + freshPng.height = cropped.height; + freshPng.data = cropped.data; + } + } // Diff the two PNGs. ``pixelmatch`` returns the // absolute count of differing pixels (not the