From cd5c382c6877a5ea63779b66a12c320c1b80610f Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 19 Jun 2026 11:09:01 -0400 Subject: [PATCH 01/11] fastapi app init --- .pre-commit-config.yaml | 32 +- federation/.envs/example/sync.env | 1 + federation/.gitignore | 6 + federation/Dockerfile | 15 + federation/certs/.gitignore | 3 + federation/compose.yaml | 31 + federation/federation.toml | 12 + federation/justfile | 70 ++ federation/pyproject.toml | 154 ++++ federation/scripts/generate-dev-certs.sh | 239 ++++++ federation/sds_federation/__init__.py | 0 federation/sds_federation/routes/__init__.py | 0 federation/sds_federation/schemas/__init__.py | 0 .../sds_federation/services/__init__.py | 0 federation/uv.lock | 687 ++++++++++++++++++ 15 files changed, 1240 insertions(+), 10 deletions(-) create mode 100644 federation/.envs/example/sync.env create mode 100644 federation/.gitignore create mode 100644 federation/Dockerfile create mode 100644 federation/certs/.gitignore create mode 100644 federation/compose.yaml create mode 100644 federation/federation.toml create mode 100644 federation/justfile create mode 100644 federation/pyproject.toml create mode 100755 federation/scripts/generate-dev-certs.sh create mode 100644 federation/sds_federation/__init__.py create mode 100644 federation/sds_federation/routes/__init__.py create mode 100644 federation/sds_federation/schemas/__init__.py create mode 100644 federation/sds_federation/services/__init__.py create mode 100644 federation/uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e4b71e55f..0b8862759 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,6 +52,7 @@ repos: hooks: - id: django-upgrade args: ["--target-version", "4.2"] + exclude: ^federation/ # runs the ruff linter and formatter - repo: https://github.com/astral-sh/ruff-pre-commit @@ -59,25 +60,36 @@ repos: hooks: # linter - id: ruff-check # runs ruff check --force-exclude + exclude: ^federation/ args: - # we have to pick one ruff config between gateway and sdk... - # the SDK has additional constraints for having to support multiple - # python versions, so it's a safer choice for a project-wide config; - # though be aware that this might omit PyUpgrade improvements that - # could be applied to the gateway side. [ - # --verbose, # for debugging - # --show-files, # for debugging - # --show-fixes, # for debugging --no-cache, --fix, --exit-non-zero-on-fix, --config, "sdk/pyproject.toml", - ] # --show-settings, # for debugging + ] + - id: ruff-check + alias: ruff-check-federation + name: ruff-check (federation) + files: ^federation/.*\.py$ + args: + [ + --no-cache, + --fix, + --exit-non-zero-on-fix, + --config, + "federation/pyproject.toml", + ] # formatter - id: ruff-format # runs ruff format --force-exclude + exclude: ^federation/ args: [--config, "sdk/pyproject.toml"] + - id: ruff-format + alias: ruff-format-federation + name: ruff-format (federation) + files: ^federation/.*\.py$ + args: [--config, "federation/pyproject.toml"] # linter for django templates - repo: https://github.com/Riverside-Healthcare/djLint @@ -100,7 +112,7 @@ repos: hooks: - id: deptry name: deptry - entry: bash -c "cd sdk && uv run deptry .; cd ../gateway && uv run deptry ." + entry: bash -c "cd sdk && uv run deptry .; cd ../gateway && uv run deptry .; cd ../federation && uv run deptry ." language: system pass_filenames: false # run once per commit, not per file types: [python] diff --git a/federation/.envs/example/sync.env b/federation/.envs/example/sync.env new file mode 100644 index 000000000..73afb232b --- /dev/null +++ b/federation/.envs/example/sync.env @@ -0,0 +1 @@ +FEDERATION_GATEWAY_API_KEY= \ No newline at end of file diff --git a/federation/.gitignore b/federation/.gitignore new file mode 100644 index 000000000..47e8b4e6c --- /dev/null +++ b/federation/.gitignore @@ -0,0 +1,6 @@ +# Dev PKI material — generate with scripts/generate-dev-certs.sh + +__pycache__/ + +.envs/* +!/.envs/example \ No newline at end of file diff --git a/federation/Dockerfile b/federation/Dockerfile new file mode 100644 index 000000000..ae220634a --- /dev/null +++ b/federation/Dockerfile @@ -0,0 +1,15 @@ +FROM docker.io/python:3.13-slim-trixie + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY pyproject.toml ./ +RUN uv sync --no-dev + +COPY sds_federation ./sds_federation + +EXPOSE 8000 +CMD ["uv", "run", "uvicorn", "sds_federation.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/federation/certs/.gitignore b/federation/certs/.gitignore new file mode 100644 index 000000000..812858d23 --- /dev/null +++ b/federation/certs/.gitignore @@ -0,0 +1,3 @@ +# Dev PKI material — generate with scripts/generate-dev-certs.sh +* +!.gitignore diff --git a/federation/compose.yaml b/federation/compose.yaml new file mode 100644 index 000000000..73e6f95c3 --- /dev/null +++ b/federation/compose.yaml @@ -0,0 +1,31 @@ +services: + sds-federation-local-sync: + build: + context: . + dockerfile: Dockerfile + image: sds-federation-local-sync + container_name: sds-federation-local-sync + env_file: + - .envs/local/sync.env + environment: + FEDERATION_CONFIG_PATH: /etc/sds/federation.toml + GATEWAY_INTERNAL_BASE_URL: http://sds-gateway-local-app:8000/api/v1 + REDIS_URL: redis://sds-gateway-local-redis:6379/0 + OPENSEARCH_HOST: sds-gateway-local-opensearch + volumes: + - ./federation.toml:/etc/sds/federation.toml:ro + - ./certs:/etc/sds/certs:ro + ports: + - "8001:8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 5s + retries: 3 + networks: + - sds-network-local + +networks: + sds-network-local: + external: true + name: sds-network-local \ No newline at end of file diff --git a/federation/federation.toml b/federation/federation.toml new file mode 100644 index 000000000..eb2693e9f --- /dev/null +++ b/federation/federation.toml @@ -0,0 +1,12 @@ +[site] +name = "crc" +fqdn = "sds.crc.nd.edu" +display_name = "Notre Dame CRC" + +[[peers]] +name = "haystack" +fqdn = "merrimack.haystack.mit.edu" +display_name = "MIT Haystack" +gateway_api_base = "https://merrimack.haystack.mit.edu/api/v1" +sync_service_url = "https://merrimack.haystack.mit.edu/sync/" +ca_cert_path = "/etc/sds/certs/haystack-ca.pem" \ No newline at end of file diff --git a/federation/justfile b/federation/justfile new file mode 100644 index 000000000..91618b494 --- /dev/null +++ b/federation/justfile @@ -0,0 +1,70 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +compose_file := "compose.yaml" +docker_compose := "COMPOSE_FILE=" + compose_file + " docker compose" + +alias run := up + +default: + @just --list + +# install runtime + dev deps (pytest) +[group('setup')] +sync: + uv sync --extra dev + +[group('qa')] +test-regression +args='': + uv run pytest -m regression {{ args }} + +[group('qa')] +test-integration +args='': + uv run pytest -m integration {{ args }} + +[group('qa')] +test +args='': + uv run pytest {{ args }} + +[group('qa')] +test-q: + uv run pytest -q + +# Run federation ruff hooks from repo root (requires gateway dev deps / pre-commit install) +[group('qa')] +pre-commit +args='': + cd .. && uv run --directory gateway --extra local pre-commit run ruff-check-federation --all-files {{ args }} + cd .. && uv run --directory gateway --extra local pre-commit run ruff-format-federation --all-files {{ args }} + +# dev PKI for mTLS experiments (see docs/mtls-certificates.md) +[group('setup')] +gen-certs +args='': + ./scripts/generate-dev-certs.sh all {{ args }} + +[group('setup')] +gen-certs-ca: + ./scripts/generate-dev-certs.sh ca + +# publish simulated federation:events to Redis +[group('dev')] +simulate-redis +args='': + REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" uv run python scripts/simulate_redis_event.py {{ args }} + +[group('docker')] +build +args='': + {{ docker_compose }} build {{ args }} + +[group('docker')] +up +args='': + {{ docker_compose }} up -d --remove-orphans {{ args }} + +[group('docker')] +down +args='': + {{ docker_compose }} down {{ args }} + +[group('docker')] +logs +args='': + {{ docker_compose }} logs --tail 1000 -f {{ args }} + +[group('docker')] +dc +args='': + {{ docker_compose }} {{ args }} diff --git a/federation/pyproject.toml b/federation/pyproject.toml new file mode 100644 index 000000000..c923eb36d --- /dev/null +++ b/federation/pyproject.toml @@ -0,0 +1,154 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["sds_federation"] + +[project] + name = "sds-federation" + version = "0.1.0" + description = "SDS Federation" + requires-python = ">=3.13,<3.14" + dependencies = [ + "fastapi>=0.115.6", + "httpx>=0.28.1", + "loguru>=0.7.2", + "opensearch-py>=2.8.0", + "pydantic>=2.11.0", + "redis>=5.2.1", + "uvicorn[standard]>=0.34.0", + ] + +[project.optional-dependencies] +dev = [ + "deptry>=0.24.0", + "pytest>=8.3.0", + "pytest-asyncio>=0.25.0", + "ruff>=0.15.0", +] + +[tool.deptry] + extend_exclude = ["docs/"] + + [tool.deptry.per_rule_ignores] + DEP002 = [ + "deptry", + "pytest", + "pytest-asyncio", + "ruff", + "uvicorn", + ] + + [tool.deptry.package_module_name_map] + opensearch-py = "opensearchpy" + +[tool.ruff] + exclude = [ + ".git", + ".ruff_cache", + ".venv", + "build", + "dist", + "docs/", + ] + indent-width = 4 + line-length = 88 + src = ["/federation/"] + target-version = "py313" + + [tool.ruff.lint] + ignore = [ + "COM812", + "ISC001", + "RUF012", + "S101", + "S104", + "SIM102", + "TRY003", + "EM101", + "EM102", + "TC002", + ] + select = [ + "F", + "E", + "W", + "C90", + "I", + "N", + "UP", + "YTT", + "ASYNC", + "S", + "BLE", + "FBT", + "B", + "A", + "COM", + "C4", + "DTZ", + "T10", + "EM", + "EXE", + "ISC", + "ICN", + "LOG", + "G", + "INP", + "PIE", + "T20", + "PYI", + "PT", + "Q", + "RSE", + "RET", + "SLF", + "SLOT", + "SIM", + "TID", + "TC", + "PTH", + "ERA", + "PGH", + "PL", + "TRY", + "FLY", + "PERF", + "FURB", + "RUF", + "FAST", + ] + fixable = ["ALL"] + + [tool.ruff.lint.isort] + force-single-line = true + + [tool.ruff.lint.pylint] + max-args = 9 + + [tool.ruff.lint.per-file-ignores] + "**/tests/**" = [ + "FBT001", + "FBT002", + "PLR2004", + "E501", + "PT011", + "B017", + "PLC0415", + ] + "**/scripts/**" = ["T201", "EXE001", "E501"] + + [tool.ruff.format] + indent-style = "space" + line-ending = "auto" + quote-style = "double" + skip-magic-trailing-comma = false + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "integration: tests that exercise FastAPI routes or multi-step pipeline", + "regression: contract and indexer behavior guards", +] diff --git a/federation/scripts/generate-dev-certs.sh b/federation/scripts/generate-dev-certs.sh new file mode 100755 index 000000000..b9067d950 --- /dev/null +++ b/federation/scripts/generate-dev-certs.sh @@ -0,0 +1,239 @@ +#!/usr/bin/env bash +# Generate a local dev PKI for federation mTLS experiments. +# +# Documentation: docs/mtls-certificates.md +# +# Output directory defaults to federation/certs/ (mounted at /etc/sds/certs in compose). +# +# Usage: +# ./scripts/generate-dev-certs.sh all +# ./scripts/generate-dev-certs.sh ca +# ./scripts/generate-dev-certs.sh client --site-name crc +# ./scripts/generate-dev-certs.sh server --server-cn localhost --dns localhost --ip 127.0.0.1 +# +# In federation.toml (peer HTTPS signed by this CA): +# ca_cert_path = "/etc/sds/certs/federation-ca.pem" +# +# Client cert/key (${SITE}-client.pem/.key) are for future outbound mTLS in sync; +# Traefik/nginx uses local-sync-server.pem/.key for HTTPS on /sync/. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FEDERATION_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CERT_DIR="${CERT_DIR:-${FEDERATION_ROOT}/certs}" + +CA_NAME="${CA_NAME:-federation-ca}" +CA_DAYS="${CA_DAYS:-3650}" +CERT_DAYS="${CERT_DAYS:-825}" +SITE_NAME="${SITE_NAME:-crc}" +SERVER_CN="${SERVER_CN:-localhost}" +SERVER_DNS=() +SERVER_IPS=() +FORCE=0 + +usage() { + sed -n '2,12p' "$0" | tail -n +2 + cat < %s\n' "$*" +} + +need_openssl() { + command -v openssl >/dev/null 2>&1 || { + echo "openssl not found in PATH" >&2 + exit 1 + } +} + +ensure_cert_dir() { + mkdir -p "${CERT_DIR}" +} + +write_if_missing() { + local path=$1 + if [[ -f "${path}" && "${FORCE}" -ne 1 ]]; then + echo "Refusing to overwrite ${path} (use --force)" >&2 + exit 1 + fi +} + +generate_ca() { + local ca_pem="${CERT_DIR}/${CA_NAME}.pem" + local ca_key="${CERT_DIR}/${CA_NAME}.key" + + write_if_missing "${ca_pem}" + write_if_missing "${ca_key}" + + log "CA -> ${ca_pem}" + openssl genrsa -out "${ca_key}" 4096 + chmod 600 "${ca_key}" + openssl req -x509 -new -nodes -key "${ca_key}" -sha256 -days "${CA_DAYS}" \ + -out "${ca_pem}" \ + -subj "/CN=SDS Federation Dev CA/O=Local Dev" +} + +generate_client() { + local ca_pem="${CERT_DIR}/${CA_NAME}.pem" + local ca_key="${CERT_DIR}/${CA_NAME}.key" + local client_key="${CERT_DIR}/${SITE_NAME}-client.key" + local client_csr="${CERT_DIR}/${SITE_NAME}-client.csr" + local client_pem="${CERT_DIR}/${SITE_NAME}-client.pem" + + [[ -f "${ca_pem}" && -f "${ca_key}" ]] || { + echo "Run 'ca' first (missing ${CA_NAME}.pem/.key)" >&2 + exit 1 + } + + write_if_missing "${client_pem}" + write_if_missing "${client_key}" + + log "Client (${SITE_NAME}) -> ${client_pem}" + openssl genrsa -out "${client_key}" 2048 + chmod 600 "${client_key}" + openssl req -new -key "${client_key}" -out "${client_csr}" \ + -subj "/CN=${SITE_NAME}-sync/O=SDS Federation" + openssl x509 -req -in "${client_csr}" -CA "${ca_pem}" -CAkey "${ca_key}" \ + -CAcreateserial -out "${client_pem}" -days "${CERT_DAYS}" -sha256 +} + +generate_server() { + local ca_pem="${CERT_DIR}/${CA_NAME}.pem" + local ca_key="${CERT_DIR}/${CA_NAME}.key" + local server_key="${CERT_DIR}/local-sync-server.key" + local server_csr="${CERT_DIR}/local-sync-server.csr" + local server_pem="${CERT_DIR}/local-sync-server.pem" + local cnf="${CERT_DIR}/server-openssl.cnf" + + [[ -f "${ca_pem}" && -f "${ca_key}" ]] || { + echo "Run 'ca' first (missing ${CA_NAME}.pem/.key)" >&2 + exit 1 + } + + if [[ ${#SERVER_DNS[@]} -eq 0 ]]; then + SERVER_DNS=(localhost) + fi + if [[ ${#SERVER_IPS[@]} -eq 0 ]]; then + SERVER_IPS=(127.0.0.1) + fi + + write_if_missing "${server_pem}" + write_if_missing "${server_key}" + + { + echo "[req]" + echo "distinguished_name = req_distinguished_name" + echo "req_extensions = v3_req" + echo "prompt = no" + echo "[req_distinguished_name]" + echo "CN = ${SERVER_CN}" + echo "[v3_req]" + echo "subjectAltName = @alt_names" + echo "[alt_names]" + local i=1 + for dns in "${SERVER_DNS[@]}"; do + echo "DNS.${i} = ${dns}" + i=$((i + 1)) + done + i=1 + for ip in "${SERVER_IPS[@]}"; do + echo "IP.${i} = ${ip}" + i=$((i + 1)) + done + } >"${cnf}" + + log "Server TLS -> ${server_pem} (CN=${SERVER_CN})" + openssl genrsa -out "${server_key}" 2048 + chmod 600 "${server_key}" + openssl req -new -key "${server_key}" -out "${server_csr}" -config "${cnf}" + openssl x509 -req -in "${server_csr}" -CA "${ca_pem}" -CAkey "${ca_key}" \ + -CAcreateserial -out "${server_pem}" -days "${CERT_DAYS}" -sha256 \ + -extensions v3_req -extfile "${cnf}" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --cert-dir) + CERT_DIR=$2 + shift 2 + ;; + --site-name) + SITE_NAME=$2 + shift 2 + ;; + --server-cn) + SERVER_CN=$2 + shift 2 + ;; + --dns) + SERVER_DNS+=("$2") + shift 2 + ;; + --ip) + SERVER_IPS+=("$2") + shift 2 + ;; + --force) + FORCE=1 + shift + ;; + ca | client | server | all) + COMMAND=$1 + shift + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +COMMAND="${COMMAND:-all}" + +need_openssl +ensure_cert_dir + +case "${COMMAND}" in + ca) + generate_ca + ;; + client) + generate_client + ;; + server) + generate_server + ;; + all) + generate_ca + generate_client + generate_server + log "Done. Trust bundle for peers: ${CERT_DIR}/${CA_NAME}.pem" + ;; + *) + usage >&2 + exit 1 + ;; +esac diff --git a/federation/sds_federation/__init__.py b/federation/sds_federation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/federation/sds_federation/routes/__init__.py b/federation/sds_federation/routes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/federation/sds_federation/schemas/__init__.py b/federation/sds_federation/schemas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/federation/sds_federation/services/__init__.py b/federation/sds_federation/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/federation/uv.lock b/federation/uv.lock new file mode 100644 index 000000000..b44760f59 --- /dev/null +++ b/federation/uv.lock @@ -0,0 +1,687 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "deptry" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "packaging" }, + { name = "requirements-parser" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/b2/50ccc99362ae7757342978b7ecb3b98e47fade721fd617d74db1948ec3a1/deptry-0.25.1.tar.gz", hash = "sha256:45c8cd982c85cd4faae573ddff6920de7eec735336db6973f26a765ae7950f7d", size = 509748, upload-time = "2026-03-18T23:22:18.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/1d/b538dc635e873b25360d761cfe1fa0ccd7d6c69b698047e552f33401e60d/deptry-0.25.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a4dd1148db24a1ddacfa8b840836c6019c2f864fcb7579dd089fd217606338c8", size = 1850319, upload-time = "2026-03-18T23:22:15.65Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a9/511477a8f0ae4f6021d68a80bdca77e7ffb0722008dc24ee5d9ef49f5c88/deptry-0.25.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c67c666d916ef12013c0772e40d78be0f21577a495d8d99ec5fcb18c332d393d", size = 1759259, upload-time = "2026-03-18T23:22:30.853Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4b/c9f0bdda410912a6df79a789cb118fa29acae02a397794ead3c84adcda5c/deptry-0.25.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58d39279828dbf4efc1abb40bf50a71b21499c36759bed5a8d8a3c0e3149b091", size = 1872012, upload-time = "2026-03-18T23:22:19.145Z" }, + { url = "https://files.pythonhosted.org/packages/72/9c/6f6f9125bac74b5d5d2af89536cbdb3fa159b6466aa097b74e7e85e8e030/deptry-0.25.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14bfcc28b4326ed8c6abb30691b19077d4ef8613cfba6c37ef5b1f471775bf6f", size = 1926575, upload-time = "2026-03-18T23:22:11.269Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/2a5e705a7f898295966ade67bd1223e2af96da433e25b39f6b9483ba2c7b/deptry-0.25.1-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:555f5f9a487899ec9bf301eecba1745e14d212c4b354f4d3a5fd691e907366d3", size = 2050816, upload-time = "2026-03-18T23:22:27.439Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c6/50f189a894e1f3bf21266299112c8a06cb731838976e1b9a9cadd0b4a86e/deptry-0.25.1-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:18d21b3545ab2bfec53f3f45c6f5f201d55f713323327f8d12674505469ae6b7", size = 2145416, upload-time = "2026-03-18T23:22:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6a/3f82f7a06217778282bc4456af1b4ffb3bc4b2c8e7891d00e8323f9ad0b8/deptry-0.25.1-cp310-abi3-win_amd64.whl", hash = "sha256:b59a560cb7dffb21832a98bb80d33d614cfb5630ea36ce21833eabf4eae3df99", size = 1718489, upload-time = "2026-03-18T23:22:28.589Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7f/cd6b3ac8cf95f2f1c5c7a74ff6452e9098af89a9b56607381f677880641e/deptry-0.25.1-cp310-abi3-win_arm64.whl", hash = "sha256:6efffd8116fb9d2c45a251382ce4ce1c38dbb17179f581ec9231ed5390f7fc12", size = 1647020, upload-time = "2026-03-18T23:22:23.311Z" }, +] + +[[package]] +name = "events" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/ed/e47dec0626edd468c84c04d97769e7ab4ea6457b7f54dcb3f72b17fcd876/Events-0.5-py3-none-any.whl", hash = "sha256:a7286af378ba3e46640ac9825156c93bdba7502174dd696090fdfcd4d80a1abd", size = 6758, upload-time = "2023-07-31T08:23:13.645Z" }, +] + +[[package]] +name = "fastapi" +version = "0.137.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/29/cc5819dc24d3daa80cdaa1aec023bf8652a70dd7fd1c96b0b225c99a7690/fastapi-0.137.2.tar.gz", hash = "sha256:b9d893bebc97dcfbdcb1917e88a292d062844ea19445a5fa4f7eb28c4baea9e3", size = 410332, upload-time = "2026-06-18T06:58:24.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/ed/0c6b644e99fb5697d8bdcd36cdb47c52e77a63fc7a1514b1f03a6ecab955/fastapi-0.137.2-py3-none-any.whl", hash = "sha256:791d36261e916a98b25ac85ee591bc3db159394070f6d3d096d94fb378f60ce2", size = 122252, upload-time = "2026-06-18T06:58:26.074Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +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 = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "opensearch-protobufs" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/2f/e0cc165af7bb7b44cb00023b9fcaa01a28d1755a059ede28d0cd970c3cec/opensearch_protobufs-1.2.0-py3-none-any.whl", hash = "sha256:e806730894d0a0c8cdaa3cdbe07e4b7c46e1823f453777b36caf39e9cba28e2c", size = 54751, upload-time = "2026-01-22T18:51:56.805Z" }, +] + +[[package]] +name = "opensearch-py" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "events" }, + { name = "opensearch-protobufs" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/9e/e77844cb2d625ca32331bfdd28930113b3778399c01dd5f1c350ceb55e65/opensearch_py-3.2.0.tar.gz", hash = "sha256:f40fb3a295275422df2ad6d9459f667af94472d5a9e567072e9ecf163eb22613", size = 259927, upload-time = "2026-04-27T18:17:50.467Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/63/7abb96bf2e3619acbd27de99e60619bfacfb7c55b68c4792a258e6d92871/opensearch_py-3.2.0-py3-none-any.whl", hash = "sha256:721a0d3b13fbed9e82278aed748285cf63a1855354ab7e73e3d4992d1b93418b", size = 387286, upload-time = "2026-04-27T18:17:48.658Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "redis" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/ae/ed461cca5780b5fc8b9fe8ca0ed98d89508645fb9d880c24cc42c087678f/redis-8.0.0.tar.gz", hash = "sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49", size = 5101591, upload-time = "2026-05-28T12:45:13.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e3/b519734372d305bd547534a9f32e4ce9f98552af753dce72cf3483a0ff0b/redis-8.0.0-py3-none-any.whl", hash = "sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7", size = 499870, upload-time = "2026-05-28T12:45:11.697Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requirements-parser" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/1a/5f3c22d38bf1d87d1f4a961489d9eba35c4370a21395562d94410cdd0e73/requirements_parser-0.13.1.tar.gz", hash = "sha256:78811383b2089b6c5197a1431bc2c12ff950245edca39a23eea3460782038dd3", size = 22783, upload-time = "2026-06-18T07:52:25.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/f9/15b44d5e4401b0013bbcefe3c09d7bfddcce28cc3d41b1d3077bcedf5b1f/requirements_parser-0.13.1-py3-none-any.whl", hash = "sha256:6e385663eb32589d16e5b22bb6e5251a57908e73803ffff438b53cd6ea2056e0", size = 14926, upload-time = "2026-06-18T07:52:24.171Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, +] + +[[package]] +name = "sds-federation" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "loguru" }, + { name = "opensearch-py" }, + { name = "pydantic" }, + { name = "redis" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "deptry" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "deptry", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "fastapi", specifier = ">=0.115.6" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "loguru", specifier = ">=0.7.2" }, + { name = "opensearch-py", specifier = ">=2.8.0" }, + { name = "pydantic", specifier = ">=2.11.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25.0" }, + { name = "redis", specifier = ">=5.2.1" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] From 4497e856908303c05df938113129cc3a86327ea9 Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 19 Jun 2026 11:10:58 -0400 Subject: [PATCH 02/11] app models, doc schemas, webhook endpoints, peer sync/registry/bootstrap services --- federation/sds_federation/main.py | 76 ++++++ federation/sds_federation/models.py | 58 +++++ federation/sds_federation/routes/health.py | 9 + federation/sds_federation/routes/webhooks.py | 97 ++++++++ federation/sds_federation/schemas/webhooks.py | 112 +++++++++ .../sds_federation/services/bootstrap.py | 216 ++++++++++++++++++ .../sds_federation/services/fed_index.py | 78 +++++++ .../sds_federation/services/local_events.py | 164 +++++++++++++ .../sds_federation/services/peer_registry.py | 26 +++ .../sds_federation/services/peer_sync.py | 33 +++ 10 files changed, 869 insertions(+) create mode 100644 federation/sds_federation/main.py create mode 100644 federation/sds_federation/models.py create mode 100644 federation/sds_federation/routes/health.py create mode 100644 federation/sds_federation/routes/webhooks.py create mode 100644 federation/sds_federation/schemas/webhooks.py create mode 100644 federation/sds_federation/services/bootstrap.py create mode 100644 federation/sds_federation/services/fed_index.py create mode 100644 federation/sds_federation/services/local_events.py create mode 100644 federation/sds_federation/services/peer_registry.py create mode 100644 federation/sds_federation/services/peer_sync.py diff --git a/federation/sds_federation/main.py b/federation/sds_federation/main.py new file mode 100644 index 000000000..6ffffc79e --- /dev/null +++ b/federation/sds_federation/main.py @@ -0,0 +1,76 @@ +import asyncio +import os +from contextlib import asynccontextmanager +from contextlib import suppress +from datetime import UTC +from datetime import datetime + +from fastapi import FastAPI +from loguru import logger +from opensearchpy import OpenSearch + +from sds_federation.models import load_federation_config +from sds_federation.routes.health import health_router +from sds_federation.routes.webhooks import webhooks_router +from sds_federation.services.bootstrap import run_bootstrap +from sds_federation.services.fed_index import FederatedAssetIndexer +from sds_federation.services.local_events import build_gateway_http_client +from sds_federation.services.local_events import run_federation_subscriber +from sds_federation.services.peer_registry import PeerRegistry + +API_PREFIX = "/api/v1" + + +def _bootstrap_enabled() -> bool: + return os.environ.get("FEDERATION_BOOTSTRAP_ON_START", "true").lower() not in ( + "0", + "false", + "no", + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + config = load_federation_config() + http = build_gateway_http_client() + os_host = os.environ.get("OPENSEARCH_HOST", "opensearch") + os_port = os.environ.get("OPENSEARCH_PORT", "9200") + os_client = OpenSearch(hosts=[{"host": os_host, "port": int(os_port)}]) + app.state.config = config + app.state.http = http + app.state.fed_indexer = FederatedAssetIndexer(os_client) + app.state.peer_registry = PeerRegistry() + + if _bootstrap_enabled(): + try: + await run_bootstrap( + config, + http, + app.state.fed_indexer, + event_at=datetime.now(UTC), + ) + except Exception as exc: # noqa: BLE001 + logger.error("Federation bootstrap failed: {}", exc) + else: + logger.info( + "Federation bootstrap skipped (FEDERATION_BOOTSTRAP_ON_START=false)", + ) + + stop = asyncio.Event() + redis_url = os.environ.get("REDIS_URL", "redis://redis:6379/0") + sub_task = asyncio.create_task( + run_federation_subscriber(redis_url, http, config, stop), + ) + + yield + + stop.set() + sub_task.cancel() + with suppress(asyncio.CancelledError): + await sub_task + await http.aclose() + + +app = FastAPI(title="SDS Federation Sync", root_path="/sync", lifespan=lifespan) +app.include_router(health_router) +app.include_router(webhooks_router, prefix=API_PREFIX) diff --git a/federation/sds_federation/models.py b/federation/sds_federation/models.py new file mode 100644 index 000000000..8d4165ab0 --- /dev/null +++ b/federation/sds_federation/models.py @@ -0,0 +1,58 @@ +import os +import tomllib +from pathlib import Path + +from pydantic import AnyHttpUrl +from pydantic import BaseModel +from pydantic import Field + + +class SiteInfo(BaseModel): + name: str + fqdn: str + display_name: str + sync_service_url: AnyHttpUrl | None = None + + +class PeerInfo(BaseModel): + name: str + fqdn: str + display_name: str + gateway_api_base: AnyHttpUrl + sync_service_url: AnyHttpUrl + ca_cert_path: str = "" + gateway_export_api_key: str = "" + + +class FederationConfig(BaseModel): + site: SiteInfo + peers: list[PeerInfo] = Field(default_factory=list) + gateway_api_base: AnyHttpUrl + sync_service_url: AnyHttpUrl + + +def load_federation_config() -> FederationConfig: + path = Path(os.environ.get("FEDERATION_CONFIG_PATH", "federation.toml")) + data = tomllib.loads(path.read_text(encoding="utf-8")) + gateway_base = os.environ.get( + "GATEWAY_INTERNAL_BASE_URL", + os.environ.get("GATEWAY_API_BASE_URL"), + ) + if not gateway_base: + site_table = data.get("site", {}) + fqdn = site_table.get("fqdn", "localhost") + gateway_base = f"http://{fqdn}/api/v1" + site_table = data.get("site", {}) + fqdn = site_table.get("fqdn", "localhost") + sync_url = os.environ.get("FEDERATION_SYNC_SERVICE_URL") or site_table.get( + "sync_service_url", + ) + if not sync_url: + sync_url = f"http://{fqdn}/sync/" + return FederationConfig.model_validate( + { + **data, + "gateway_api_base": gateway_base.rstrip("/"), + "sync_service_url": str(sync_url).rstrip("/"), + }, + ) diff --git a/federation/sds_federation/routes/health.py b/federation/sds_federation/routes/health.py new file mode 100644 index 000000000..5d1a2df18 --- /dev/null +++ b/federation/sds_federation/routes/health.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +health_router = APIRouter() + + +@health_router.get("/health") +async def health(): + """Check the health of the federation""" + return {"message": "OK"} diff --git a/federation/sds_federation/routes/webhooks.py b/federation/sds_federation/routes/webhooks.py new file mode 100644 index 000000000..c11207a2d --- /dev/null +++ b/federation/sds_federation/routes/webhooks.py @@ -0,0 +1,97 @@ +from datetime import UTC +from datetime import datetime + +from fastapi import APIRouter +from fastapi import HTTPException +from fastapi import Request + +from sds_federation.schemas.webhooks import AssetTypeEnum +from sds_federation.schemas.webhooks import AssetUpdatedWebhook +from sds_federation.schemas.webhooks import SiteHelloWebhook +from sds_federation.services.fed_index import FederatedAssetIndexer +from sds_federation.services.peer_registry import PeerRegistry + +webhooks_router = APIRouter(tags=["webhooks"]) + + +def _indexer(request: Request) -> FederatedAssetIndexer: + indexer = getattr(request.app.state, "fed_indexer", None) + if indexer is None: + raise HTTPException(status_code=503, detail="Indexer not ready") + return indexer + + +def _peer_registry(request: Request) -> PeerRegistry: + registry = getattr(request.app.state, "peer_registry", None) + if registry is None: + raise HTTPException(status_code=503, detail="Peer registry not ready") + return registry + + +def _allowed_origin_sites(request: Request, payload: AssetUpdatedWebhook) -> None: + config = request.app.state.config + allowed = {peer.name for peer in config.peers} | {config.site.name} + if payload.site_name not in allowed: + raise HTTPException(status_code=403, detail="Unknown origin site") + + +@webhooks_router.post("/webhook/dataset-updated") +async def dataset_updated(payload: AssetUpdatedWebhook, request: Request) -> dict: + _allowed_origin_sites(request, payload) + if payload.asset is None or payload.asset_type is not AssetTypeEnum.DATASET: + raise HTTPException( + status_code=422, + detail="Dataset body required for dataset-updated webhook.", + ) + try: + _indexer(request).apply_asset_event( + event_type=payload.event_type, + event_at=payload.timestamp, + site_name=payload.site_name, + asset=payload.asset, + asset_type=payload.asset_type, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return {"status": "accepted"} + + +@webhooks_router.post("/webhook/capture-updated") +async def capture_updated(payload: AssetUpdatedWebhook, request: Request) -> dict: + _allowed_origin_sites(request, payload) + if payload.asset is None or payload.asset_type is not AssetTypeEnum.CAPTURE: + raise HTTPException( + status_code=422, + detail="Capture body required for capture-updated webhook.", + ) + try: + _indexer(request).apply_asset_event( + event_type=payload.event_type, + event_at=payload.timestamp, + site_name=payload.site_name, + asset=payload.asset, + asset_type=payload.asset_type, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return {"status": "accepted"} + + +@webhooks_router.post("/webhook/site-hello") +async def site_hello(payload: SiteHelloWebhook, request: Request) -> dict: + config = request.app.state.config + if payload.site_name == config.site.name: + raise HTTPException( + status_code=422, + detail="Cannot register self via site-hello", + ) + allowed = {peer.name for peer in config.peers} + if payload.site_name not in allowed: + raise HTTPException(status_code=403, detail="Unknown registering site") + + hello = payload + if hello.timestamp is None: + hello = hello.model_copy(update={"timestamp": datetime.now(UTC)}) + + _peer_registry(request).register(hello) + return {"status": "registered", "site_name": hello.site_name} diff --git a/federation/sds_federation/schemas/webhooks.py b/federation/sds_federation/schemas/webhooks.py new file mode 100644 index 000000000..7eb4f482d --- /dev/null +++ b/federation/sds_federation/schemas/webhooks.py @@ -0,0 +1,112 @@ +from datetime import datetime +from enum import StrEnum +from typing import Any +from uuid import UUID + +from pydantic import AnyHttpUrl +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field + + +class FederationEventType(StrEnum): + CREATED = "created" + UPDATED = "updated" + DELETED = "deleted" + + +class AssetTypeEnum(StrEnum): + DATASET = "dataset" + CAPTURE = "capture" + + @property + def export_path(self) -> str: + return f"/federation/export/{self.value}s/" + + @property + def webhook_path(self) -> str: + return f"/webhook/{self.value}-updated" + + @property + def doc_class(self) -> type[BaseModel]: + if self == AssetTypeEnum.DATASET: + return FederatedDatasetDoc + return FederatedCaptureDoc + + @property + def index_name(self) -> str: + return f"fed-{self.value}s" + + +class FederatedDatasetDoc(BaseModel): + """Must match gateway DatasetFederationSerializer output keys exactly.""" + + model_config = ConfigDict(extra="forbid") + + uuid: UUID + name: str + status: str + status_display: str + abstract: str = "" + description: str = "" + doi: str = "" + authors: list[Any] = Field(default_factory=list) + license: str = "" + keywords: list[str] = Field(default_factory=list) + institutions: list[Any] = Field(default_factory=list) + release_date: str | None = None + repository: str = "" + version: int = 1 + website: str = "" + provenance: dict[str, Any] | list[Any] | None = None + citation: dict[str, Any] | list[Any] | None = None + other: dict[str, Any] | list[Any] | None = None + created_at: str | None = None + is_public: bool = False + owner_name: str = "" + updated_at: str | None = None + site_name: str + size: int = 0 + capture_count: int = 0 + capture_file_count: int = 0 + artifact_file_count: int = 0 + + +class FederatedCaptureDoc(BaseModel): + """Must match gateway CaptureFederationSerializer output keys exactly.""" + + model_config = ConfigDict(extra="forbid") + + uuid: UUID + name: str = "" + capture_type: str + channel: str = "" + scan_group: UUID | str | None = None + top_level_dir: str = "" + created_at: str | None = None + updated_at: str | None = None + site_name: str + file_count: int = 0 + size: int = 0 + capture_props: dict[str, Any] = Field(default_factory=dict) + dataset_ids: list[str] = Field(default_factory=list) + + +class AssetUpdatedWebhook(BaseModel): + event_type: FederationEventType + timestamp: datetime + site_name: str + asset: FederatedDatasetDoc | FederatedCaptureDoc | None = None + asset_type: AssetTypeEnum + + +class SiteHelloWebhook(BaseModel): + """Remote sync registration after bootstrap.""" + + model_config = ConfigDict(extra="forbid") + + site_name: str + fqdn: str + display_name: str = "" + sync_service_url: AnyHttpUrl + timestamp: datetime | None = None diff --git a/federation/sds_federation/services/bootstrap.py b/federation/sds_federation/services/bootstrap.py new file mode 100644 index 000000000..cd9fa15f9 --- /dev/null +++ b/federation/sds_federation/services/bootstrap.py @@ -0,0 +1,216 @@ +"""Bootstrap federated metadata from gateway export APIs and register with peers.""" + +from __future__ import annotations + +import os +from datetime import UTC +from datetime import datetime +from typing import TYPE_CHECKING + +import httpx +from loguru import logger + +from sds_federation.models import FederationConfig +from sds_federation.models import PeerInfo +from sds_federation.schemas.webhooks import AssetTypeEnum +from sds_federation.schemas.webhooks import FederatedCaptureDoc +from sds_federation.schemas.webhooks import FederatedDatasetDoc +from sds_federation.schemas.webhooks import FederationEventType +from sds_federation.schemas.webhooks import SiteHelloWebhook +from sds_federation.services.peer_sync import peer_webhook_url + +if TYPE_CHECKING: + from sds_federation.services.fed_index import FederatedAssetIndexer + +SITE_HELLO_PATH = "/webhook/site-hello" + + +def _export_list_url(peer: PeerInfo, asset_type: AssetTypeEnum) -> str: + base = str(peer.gateway_api_base).rstrip("/") + return f"{base}{asset_type.export_path}" + + +def _gateway_auth_headers(api_key: str) -> dict[str, str]: + if not api_key: + return {} + return {"Authorization": f"Api-Key: {api_key}"} + + +def _resolve_gateway_api_key(peer: PeerInfo) -> str: + if peer.gateway_export_api_key: + return peer.gateway_export_api_key + return os.environ.get("FEDERATION_GATEWAY_API_KEY", "") + + +async def _get_json( + http: httpx.AsyncClient, + url: str, + *, + api_key: str, + verify: str | bool = True, +) -> list | dict: + headers = _gateway_auth_headers(api_key) + if verify is not True and verify: + async with httpx.AsyncClient(verify=verify, timeout=http.timeout) as client: + resp = await client.get(url, headers=headers) + else: + resp = await http.get(url, headers=headers) + resp.raise_for_status() + return resp.json() + + +async def fetch_peer_export_list( + http: httpx.AsyncClient, + peer: PeerInfo, + asset_type: AssetTypeEnum, +) -> list[FederatedDatasetDoc | FederatedCaptureDoc]: + url = _export_list_url(peer, asset_type) + api_key = _resolve_gateway_api_key(peer) + data = await _get_json(http, url, api_key=api_key) + if not isinstance(data, list): + msg = f"expected list from {url}, got {type(data).__name__}" + raise TypeError(msg) + doc_class = asset_type.doc_class + return [doc_class.model_validate(item) for item in data] + + +async def bootstrap_gateway_exports( + http: httpx.AsyncClient, + peer: PeerInfo, + indexer: FederatedAssetIndexer, + *, + event_at: datetime, +) -> int: + """Pull all export lists for one gateway (local or remote). Returns doc count.""" + indexed = 0 + for asset_type in AssetTypeEnum: + try: + docs = await fetch_peer_export_list(http, peer, asset_type) + except httpx.HTTPError as exc: + logger.error( + "bootstrap export failed for {} {}: {}", + peer.name, + asset_type.value, + exc, + ) + continue + for doc in docs: + indexer.apply_asset_event( + event_type=FederationEventType.UPDATED, + event_at=event_at, + site_name=doc.site_name, + asset=doc, + asset_type=asset_type, + ) + indexed += 1 + return indexed + + +def _local_export_peer(config: FederationConfig) -> PeerInfo: + return PeerInfo( + name=config.site.name, + fqdn=config.site.fqdn, + display_name=config.site.display_name, + gateway_api_base=config.gateway_api_base, + sync_service_url=config.sync_service_url, + ) + + +async def bootstrap_local_site( + http: httpx.AsyncClient, + config: FederationConfig, + indexer: FederatedAssetIndexer, + *, + event_at: datetime, +) -> int: + peer = _local_export_peer(config) + logger.info("Bootstrapping local public metadata from {}", peer.gateway_api_base) + return await bootstrap_gateway_exports(http, peer, indexer, event_at=event_at) + + +async def bootstrap_all_peers( + config: FederationConfig, + http: httpx.AsyncClient, + indexer: FederatedAssetIndexer, + *, + event_at: datetime, +) -> int: + total = 0 + for peer in config.peers: + logger.info("Bootstrapping peer {} from {}", peer.name, peer.gateway_api_base) + total += await bootstrap_gateway_exports( + http, + peer, + indexer, + event_at=event_at, + ) + return total + + +def _site_hello_payload(config: FederationConfig) -> SiteHelloWebhook: + return SiteHelloWebhook( + site_name=config.site.name, + fqdn=config.site.fqdn, + display_name=config.site.display_name, + sync_service_url=config.sync_service_url, + timestamp=datetime.now(UTC), + ) + + +async def push_site_hello_to_peer( + http: httpx.AsyncClient, + peer: PeerInfo, + config: FederationConfig, +) -> dict: + url = peer_webhook_url(peer, SITE_HELLO_PATH) + body = _site_hello_payload(config).model_dump(mode="json") + if peer.ca_cert_path: + async with httpx.AsyncClient( + verify=peer.ca_cert_path, + timeout=http.timeout, + ) as tls_client: + resp = await tls_client.post(url, json=body) + else: + resp = await http.post(url, json=body) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + msg = f"expected dict response from site-hello, got {type(data).__name__}" + raise TypeError(msg) + return data + + +async def register_with_peers( + http: httpx.AsyncClient, + config: FederationConfig, +) -> None: + for peer in config.peers: + try: + result = await push_site_hello_to_peer(http, peer, config) + except httpx.HTTPError as exc: + logger.error("site-hello to {} failed: {}", peer.name, exc) + continue + if result.get("status") != "registered": + logger.error( + "site-hello to {} unexpected response: {}", + peer.name, + result, + ) + + +async def run_bootstrap( + config: FederationConfig, + http: httpx.AsyncClient, + indexer: FederatedAssetIndexer, + *, + event_at: datetime | None = None, +) -> None: + when = event_at or datetime.now(UTC) + local_count = await bootstrap_local_site(http, config, indexer, event_at=when) + peer_count = await bootstrap_all_peers(http, config, indexer, event_at=when) + logger.info( + "Bootstrap indexed {} local + {} peer export documents", + local_count, + peer_count, + ) + await register_with_peers(http, config) diff --git a/federation/sds_federation/services/fed_index.py b/federation/sds_federation/services/fed_index.py new file mode 100644 index 000000000..2dbea420e --- /dev/null +++ b/federation/sds_federation/services/fed_index.py @@ -0,0 +1,78 @@ +from datetime import datetime +from uuid import UUID + +from opensearchpy import OpenSearch + +from sds_federation.schemas.webhooks import AssetTypeEnum +from sds_federation.schemas.webhooks import FederatedCaptureDoc +from sds_federation.schemas.webhooks import FederatedDatasetDoc +from sds_federation.schemas.webhooks import FederationEventType + + +def doc_id(site_name: str, uuid: UUID) -> str: + return f"{site_name}:{uuid}" + + +class FederatedAssetIndexer: + def __init__(self, client: OpenSearch) -> None: + self._client = client + # MVP dedupe: production may store last_event_at on the OS doc + self._last_event: dict[str, datetime] = {} + + def _is_stale(self, site_name: str, uuid: UUID, event_at: datetime) -> bool: + key = doc_id(site_name, uuid) + prev = self._last_event.get(key) + return bool(prev is not None and event_at <= prev) + + def _mark_applied(self, site_name: str, uuid: UUID, event_at: datetime) -> None: + self._last_event[doc_id(site_name, uuid)] = event_at + + def apply_asset_event( + self, + *, + event_type: FederationEventType, + event_at: datetime, + site_name: str, + asset: FederatedDatasetDoc | FederatedCaptureDoc | None, + asset_type: AssetTypeEnum, + ) -> None: + if asset is None: + kind = asset_type.value + msg = f"{kind} body required for {kind}-updated webhook" + raise ValueError(msg) + + # TODO: validate asset type matches body for a given asset type + + if asset.site_name != site_name: + raise ValueError(f"site_name must match {asset_type.value}.site_name") + + if self._is_stale(site_name, asset.uuid, event_at): + return + + _id = doc_id(site_name, asset.uuid) + + if event_type == FederationEventType.DELETED: + self._client.update( + index=asset_type.index_name, + id=_id, + body={ + "doc": { + "is_federated_deleted": True, + "federation_event_at": event_at.isoformat(), + }, + "doc_as_upsert": True, + }, + refresh="wait_for", + ) + else: + body = asset.model_dump(mode="json") + body["is_federated_deleted"] = False + body["federation_event_at"] = event_at.isoformat() + self._client.index( + index=asset_type.index_name, + id=_id, + body=body, + refresh="wait_for", + ) + + self._mark_applied(site_name, asset.uuid, event_at) diff --git a/federation/sds_federation/services/local_events.py b/federation/sds_federation/services/local_events.py new file mode 100644 index 000000000..40a2ea705 --- /dev/null +++ b/federation/sds_federation/services/local_events.py @@ -0,0 +1,164 @@ +import json +import os +from collections.abc import Awaitable +from collections.abc import Callable +from datetime import datetime +from uuid import UUID + +import httpx +import redis.asyncio as aioredis + +from sds_federation.models import FederationConfig +from sds_federation.schemas.webhooks import AssetTypeEnum +from sds_federation.schemas.webhooks import AssetUpdatedWebhook +from sds_federation.schemas.webhooks import FederatedCaptureDoc +from sds_federation.schemas.webhooks import FederatedDatasetDoc +from sds_federation.schemas.webhooks import FederationEventType +from sds_federation.services.peer_sync import push_asset_updated_to_peers + +CHANNEL = "federation:events" + +type AssetResolver = Callable[ + [httpx.AsyncClient, FederationConfig, UUID, AssetTypeEnum], + Awaitable[FederatedDatasetDoc | FederatedCaptureDoc], +] + + +def _export_path(asset_type: AssetTypeEnum) -> str: + return "datasets" if asset_type == AssetTypeEnum.DATASET else "captures" + + +async def fetch_local_public_asset( + http: httpx.AsyncClient, + config: FederationConfig, + uuid: UUID, + asset_type: AssetTypeEnum, +) -> FederatedDatasetDoc | FederatedCaptureDoc: + base = str(config.gateway_api_base).rstrip("/") + url = f"{base}/federation/export/{_export_path(asset_type)}/{uuid}/" + resp = await http.get(url) + resp.raise_for_status() + data = resp.json() + if asset_type == AssetTypeEnum.DATASET: + return FederatedDatasetDoc.model_validate(data) + return FederatedCaptureDoc.model_validate(data) + + +def _tombstone_doc( + config: FederationConfig, + uuid: UUID, + asset_type: AssetTypeEnum, +) -> FederatedDatasetDoc | FederatedCaptureDoc: + if asset_type == AssetTypeEnum.DATASET: + return FederatedDatasetDoc( + uuid=uuid, + site_name=config.site.name, + name="", + status="", + status_display="", + ) + return FederatedCaptureDoc( + uuid=uuid, + site_name=config.site.name, + capture_type="", + ) + + +def parse_redis_event_payload( + data: dict, +) -> tuple[AssetTypeEnum, str, UUID, datetime] | None: + """Parse a gateway-style federation:events message. Returns None if invalid.""" + try: + asset_type = AssetTypeEnum(data.get("item_type")) + event_type = data["event_type"] + uuid = UUID(data["uuid"]) + timestamp = datetime.fromisoformat(data["timestamp"]) + except (KeyError, TypeError, ValueError): + return None + return asset_type, event_type, uuid, timestamp + + +async def handle_redis_asset_event( + http: httpx.AsyncClient, + config: FederationConfig, + *, + asset_type: AssetTypeEnum, + event_type: str, + uuid: UUID, + timestamp: datetime, + resolve_asset: AssetResolver | None = None, +) -> None: + fed_event = FederationEventType(event_type) + if fed_event == FederationEventType.DELETED: + asset = _tombstone_doc(config, uuid, asset_type) + else: + fetch = resolve_asset or fetch_local_public_asset + asset = await fetch(http, config, uuid, asset_type) + + payload = AssetUpdatedWebhook( + event_type=fed_event, + timestamp=timestamp, + site_name=config.site.name, + asset=asset, + asset_type=asset_type, + ) + await push_asset_updated_to_peers(http, config, payload) + + +async def dispatch_federation_redis_payload( + http: httpx.AsyncClient, + config: FederationConfig, + data: dict, + *, + resolve_asset: AssetResolver | None = None, +) -> bool: + """ + Handle one Redis pub/sub payload dict. + + Returns True if dispatched, False if the payload was ignored (invalid shape). + """ + parsed = parse_redis_event_payload(data) + if parsed is None: + return False + asset_type, event_type, uuid, timestamp = parsed + await handle_redis_asset_event( + http, + config, + asset_type=asset_type, + event_type=event_type, + uuid=uuid, + timestamp=timestamp, + resolve_asset=resolve_asset, + ) + return True + + +async def run_federation_subscriber( + redis_url: str, + http: httpx.AsyncClient, + config: FederationConfig, + stop, +) -> None: + client = aioredis.from_url(redis_url) + pubsub = client.pubsub() + await pubsub.subscribe(CHANNEL) + try: + async for message in pubsub.listen(): + if stop.is_set(): + break + if message["type"] != "message": + continue + data = json.loads(message["data"]) + + await dispatch_federation_redis_payload(http, config, data) + finally: + await pubsub.unsubscribe(CHANNEL) + await client.aclose() + + +def build_gateway_http_client() -> httpx.AsyncClient: + api_key = os.environ.get("FEDERATION_GATEWAY_API_KEY", "") + headers = {} + if api_key: + headers["Authorization"] = f"Api-Key: {api_key}" + return httpx.AsyncClient(timeout=30.0, headers=headers) diff --git a/federation/sds_federation/services/peer_registry.py b/federation/sds_federation/services/peer_registry.py new file mode 100644 index 000000000..28ace0729 --- /dev/null +++ b/federation/sds_federation/services/peer_registry.py @@ -0,0 +1,26 @@ +"""In-memory registry of peers that completed site-hello.""" + +from __future__ import annotations + +from datetime import UTC +from datetime import datetime +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from sds_federation.schemas.webhooks import SiteHelloWebhook + + +class PeerRegistry: + def __init__(self) -> None: + self._peers: dict[str, SiteHelloWebhook] = {} + self._last_seen: dict[str, datetime] = {} + + def register(self, hello: SiteHelloWebhook) -> None: + self._peers[hello.site_name] = hello + self._last_seen[hello.site_name] = datetime.now(UTC) + + def get(self, site_name: str) -> SiteHelloWebhook | None: + return self._peers.get(site_name) + + def known_site_names(self) -> set[str]: + return set(self._peers.keys()) diff --git a/federation/sds_federation/services/peer_sync.py b/federation/sds_federation/services/peer_sync.py new file mode 100644 index 000000000..0a4c4b9b9 --- /dev/null +++ b/federation/sds_federation/services/peer_sync.py @@ -0,0 +1,33 @@ +import httpx +from loguru import logger + +from sds_federation.models import FederationConfig +from sds_federation.schemas.webhooks import AssetUpdatedWebhook + + +def peer_webhook_url(peer, path: str) -> str: + base = str(peer.sync_service_url).rstrip("/") + return f"{base}/api/v1{path}" + + +async def push_asset_updated_to_peers( + http: httpx.AsyncClient, + config: FederationConfig, + payload: AssetUpdatedWebhook, +) -> None: + body = payload.model_dump(mode="json") + path = payload.asset_type.webhook_path + for peer in config.peers: + url = peer_webhook_url(peer, path) + try: + if peer.ca_cert_path: + async with httpx.AsyncClient( + verify=peer.ca_cert_path, + timeout=http.timeout, + ) as tls_client: + resp = await tls_client.post(url, json=body) + else: + resp = await http.post(url, json=body) + resp.raise_for_status() + except httpx.HTTPError as exc: + logger.error("webhook to {} failed: {}", peer.name, exc) From 7525f2c6a0d1b9b3b7f75fceb950ed133dcc04f3 Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 19 Jun 2026 11:11:33 -0400 Subject: [PATCH 03/11] test setup first pass --- federation/scripts/simulate_redis_event.py | 54 ++++++ federation/sds_federation/testing/__init__.py | 1 + .../sds_federation/testing/sample_data.py | 60 ++++++ federation/tests/__init__.py | 0 federation/tests/conftest.py | 133 +++++++++++++ federation/tests/support/__init__.py | 0 federation/tests/support/mock_opensearch.py | 19 ++ .../tests/test_integration_bootstrap.py | 106 +++++++++++ federation/tests/test_integration_pipeline.py | 73 ++++++++ federation/tests/test_integration_webhooks.py | 176 ++++++++++++++++++ federation/tests/test_redis_event_pipeline.py | 111 +++++++++++ federation/tests/test_regression_indexer.py | 114 ++++++++++++ federation/tests/test_regression_schemas.py | 64 +++++++ 13 files changed, 911 insertions(+) create mode 100644 federation/scripts/simulate_redis_event.py create mode 100644 federation/sds_federation/testing/__init__.py create mode 100644 federation/sds_federation/testing/sample_data.py create mode 100644 federation/tests/__init__.py create mode 100644 federation/tests/conftest.py create mode 100644 federation/tests/support/__init__.py create mode 100644 federation/tests/support/mock_opensearch.py create mode 100644 federation/tests/test_integration_bootstrap.py create mode 100644 federation/tests/test_integration_pipeline.py create mode 100644 federation/tests/test_integration_webhooks.py create mode 100644 federation/tests/test_redis_event_pipeline.py create mode 100644 federation/tests/test_regression_indexer.py create mode 100644 federation/tests/test_regression_schemas.py diff --git a/federation/scripts/simulate_redis_event.py b/federation/scripts/simulate_redis_event.py new file mode 100644 index 000000000..0e681fa61 --- /dev/null +++ b/federation/scripts/simulate_redis_event.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Publish a simulated federation:events message to Redis (manual integration). + +Automated tests use pytest with dispatch_federation_redis_payload and no Redis. + +Usage: + REDIS_URL=redis://localhost:6379/0 uv run python scripts/simulate_redis_event.py + REDIS_URL=... uv run python scripts/simulate_redis_event.py --uuid aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from uuid import UUID + +import redis +from sds_federation.services.local_events import CHANNEL +from sds_federation.testing.sample_data import TEST_DATASET_UUID +from sds_federation.testing.sample_data import simulated_dataset_redis_payload + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Publish simulated federation Redis event" + ) + parser.add_argument( + "--uuid", + default=str(TEST_DATASET_UUID), + help="Dataset UUID in the event payload", + ) + parser.add_argument( + "--event-type", + default="updated", + choices=("created", "updated", "deleted"), + ) + args = parser.parse_args() + + redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0") + payload = simulated_dataset_redis_payload( + uuid=UUID(args.uuid), + event_type=args.event_type, + ) + client = redis.from_url(redis_url) + receivers = client.publish(CHANNEL, json.dumps(payload)) + print(f"Published to {CHANNEL!r} on {redis_url} ({receivers} subscribers)") + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/federation/sds_federation/testing/__init__.py b/federation/sds_federation/testing/__init__.py new file mode 100644 index 000000000..808878a78 --- /dev/null +++ b/federation/sds_federation/testing/__init__.py @@ -0,0 +1 @@ +"""Test helpers for federation sync (no gateway dependency).""" diff --git a/federation/sds_federation/testing/sample_data.py b/federation/sds_federation/testing/sample_data.py new file mode 100644 index 000000000..abf33b4c6 --- /dev/null +++ b/federation/sds_federation/testing/sample_data.py @@ -0,0 +1,60 @@ +"""Fixed UUIDs and sample export docs for isolated sync tests.""" + +from __future__ import annotations + +from datetime import UTC +from datetime import datetime +from uuid import UUID + +from sds_federation.schemas.webhooks import FederatedCaptureDoc +from sds_federation.schemas.webhooks import FederatedDatasetDoc + +# Stable id used across tests and manual simulations. +TEST_DATASET_UUID = UUID("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") +TEST_CAPTURE_UUID = UUID("bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee") + +SIMULATED_REDIS_TIMESTAMP = datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC) + + +def simulated_dataset_redis_payload( + *, + uuid: UUID = TEST_DATASET_UUID, + event_type: str = "updated", +) -> dict[str, str]: + """Gateway-compatible federation:events JSON (before Redis serializes it).""" + return { + "event_type": event_type, + "item_type": "dataset", + "uuid": str(uuid), + "timestamp": SIMULATED_REDIS_TIMESTAMP.isoformat(), + } + + +def sample_federated_dataset_doc( + *, + uuid: UUID = TEST_DATASET_UUID, + site_name: str = "testsite", +) -> FederatedDatasetDoc: + return FederatedDatasetDoc( + uuid=uuid, + name="Simulated public dataset", + status="final", + status_display="Final", + site_name=site_name, + is_public=True, + owner_name="Test Owner", + ) + + +def sample_federated_capture_doc( + *, + uuid: UUID = TEST_CAPTURE_UUID, + site_name: str = "testsite", +) -> FederatedCaptureDoc: + return FederatedCaptureDoc( + uuid=uuid, + name="Simulated capture", + capture_type="drf", + channel="0", + site_name=site_name, + ) diff --git a/federation/tests/__init__.py b/federation/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/federation/tests/conftest.py b/federation/tests/conftest.py new file mode 100644 index 000000000..91771f71b --- /dev/null +++ b/federation/tests/conftest.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import httpx +import pytest +from fastapi import FastAPI +from sds_federation.models import FederationConfig +from sds_federation.models import PeerInfo +from sds_federation.models import SiteInfo +from sds_federation.routes.webhooks import webhooks_router +from sds_federation.schemas.webhooks import AssetTypeEnum +from sds_federation.schemas.webhooks import FederatedDatasetDoc +from sds_federation.services.fed_index import FederatedAssetIndexer +from sds_federation.services.local_events import AssetResolver +from sds_federation.services.peer_registry import PeerRegistry +from sds_federation.testing.sample_data import TEST_DATASET_UUID +from sds_federation.testing.sample_data import sample_federated_dataset_doc + +from tests.support.mock_opensearch import RecordingOpenSearch + +API_PREFIX = "/api/v1" +PEER_SYNC_BASE = "http://peer-sync.test" + + +def make_peer_config(*, site_name: str = "peer-one") -> FederationConfig: + """Receiver sync: allows webhooks whose site_name is listed in peers.""" + return FederationConfig( + site=SiteInfo( + name=site_name, + fqdn="peer.test", + display_name="Peer Site", + ), + gateway_api_base="http://gateway.invalid/api/v1", + sync_service_url="http://peer-one.test/sync", + peers=[ + PeerInfo( + name="testsite", + fqdn="localhost", + display_name="Originating test site", + gateway_api_base="http://gateway.invalid/api/v1", + sync_service_url="http://unused/", + ), + ], + ) + + +def build_webhook_app( + config: FederationConfig, + indexer: FederatedAssetIndexer, +) -> FastAPI: + app = FastAPI() + app.state.config = config + app.state.fed_indexer = indexer + app.state.peer_registry = PeerRegistry() + app.include_router(webhooks_router, prefix=API_PREFIX) + return app + + +@pytest.fixture +def recording_opensearch() -> RecordingOpenSearch: + return RecordingOpenSearch() + + +@pytest.fixture +def test_site_config() -> FederationConfig: + return FederationConfig( + site=SiteInfo( + name="testsite", + fqdn="localhost", + display_name="Test Site", + ), + gateway_api_base="http://gateway.invalid/api/v1", + sync_service_url="http://testsite.test/sync", + peers=[ + PeerInfo( + name="peer-one", + fqdn="peer.test", + display_name="Peer One", + gateway_api_base="http://peer-gateway.invalid/api/v1", + sync_service_url=PEER_SYNC_BASE, + ), + ], + ) + + +@pytest.fixture +def stub_dataset_resolver(test_site_config: FederationConfig) -> AssetResolver: + docs: dict[str, FederatedDatasetDoc] = { + str(TEST_DATASET_UUID): sample_federated_dataset_doc( + site_name=test_site_config.site.name, + ), + } + + async def resolve( + _http: httpx.AsyncClient, + config: FederationConfig, + uuid, + asset_type: AssetTypeEnum, + ) -> FederatedDatasetDoc: + if asset_type != AssetTypeEnum.DATASET: + msg = f"stub resolver only supports datasets, got {asset_type}" + raise ValueError(msg) + doc = docs.get(str(uuid)) + if doc is None: + msg = f"no stub document for uuid {uuid}" + raise KeyError(msg) + if doc.site_name != config.site.name: + return doc.model_copy(update={"site_name": config.site.name}) + return doc + + return resolve + + +@pytest.fixture +def peer_webhook_recorder(): + """httpx transport that records outbound peer webhook POSTs.""" + recorded: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + recorded.append(request) + return httpx.Response(200, json={"status": "accepted"}) + + transport = httpx.MockTransport(handler) + return recorded, transport + + +@pytest.fixture +def peer_webhook_stack(recording_opensearch: RecordingOpenSearch): + """In-process peer sync app + httpx client (ASGI) + OpenSearch recorder.""" + config = make_peer_config() + indexer = FederatedAssetIndexer(recording_opensearch) + app = build_webhook_app(config, indexer) + transport = httpx.ASGITransport(app=app) + return recording_opensearch, transport diff --git a/federation/tests/support/__init__.py b/federation/tests/support/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/federation/tests/support/mock_opensearch.py b/federation/tests/support/mock_opensearch.py new file mode 100644 index 000000000..12453bc29 --- /dev/null +++ b/federation/tests/support/mock_opensearch.py @@ -0,0 +1,19 @@ +"""Recording stand-in for opensearchpy.OpenSearch in tests.""" + +from __future__ import annotations + +from typing import Any + + +class RecordingOpenSearch: + def __init__(self) -> None: + self.index_calls: list[dict[str, Any]] = [] + self.update_calls: list[dict[str, Any]] = [] + + def index(self, **kwargs: Any) -> dict[str, str]: + self.index_calls.append(kwargs) + return {"result": "created"} + + def update(self, **kwargs: Any) -> dict[str, str]: + self.update_calls.append(kwargs) + return {"result": "updated"} diff --git a/federation/tests/test_integration_bootstrap.py b/federation/tests/test_integration_bootstrap.py new file mode 100644 index 000000000..7296760e2 --- /dev/null +++ b/federation/tests/test_integration_bootstrap.py @@ -0,0 +1,106 @@ +"""Integration tests for bootstrap export pull and site-hello registration.""" + +from __future__ import annotations + +import json +from datetime import UTC +from datetime import datetime +from typing import TYPE_CHECKING + +import httpx +import pytest +from sds_federation.models import FederationConfig +from sds_federation.models import PeerInfo +from sds_federation.models import SiteInfo +from sds_federation.services.bootstrap import bootstrap_gateway_exports +from sds_federation.services.bootstrap import push_site_hello_to_peer +from sds_federation.services.fed_index import FederatedAssetIndexer +from sds_federation.services.fed_index import doc_id +from sds_federation.testing.sample_data import TEST_DATASET_UUID +from sds_federation.testing.sample_data import sample_federated_dataset_doc + +from tests.conftest import make_peer_config + +if TYPE_CHECKING: + from tests.support.mock_opensearch import RecordingOpenSearch + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_bootstrap_gateway_exports_indexes_documents( + recording_opensearch: RecordingOpenSearch, +) -> None: + doc = sample_federated_dataset_doc(site_name="remote") + export_body = json.dumps([doc.model_dump(mode="json")]) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and "export/datasets" in str(request.url): + return httpx.Response(200, content=export_body.encode()) + if request.method == "GET" and "export/captures" in str(request.url): + return httpx.Response(200, json=[]) + return httpx.Response(404) + + peer = PeerInfo( + name="remote", + fqdn="remote.test", + display_name="Remote", + gateway_api_base="http://remote-gateway.test/api/v1", + sync_service_url="http://remote-sync.test", + ) + indexer = FederatedAssetIndexer(recording_opensearch) + event_at = datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http: + count = await bootstrap_gateway_exports( + http, + peer, + indexer, + event_at=event_at, + ) + + assert count == 1 + assert len(recording_opensearch.index_calls) == 1 + assert recording_opensearch.index_calls[0]["id"] == doc_id( + "remote", + TEST_DATASET_UUID, + ) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_push_site_hello_to_in_process_peer( + recording_opensearch: RecordingOpenSearch, +) -> None: + from tests.conftest import PEER_SYNC_BASE + from tests.conftest import build_webhook_app + + peer_config = make_peer_config() + app = build_webhook_app(peer_config, FederatedAssetIndexer(recording_opensearch)) + caller_config = FederationConfig( + site=SiteInfo( + name="testsite", + fqdn="localhost", + display_name="Test Site", + ), + gateway_api_base="http://gateway.invalid/api/v1", + sync_service_url="http://testsite.test/sync", + peers=[ + PeerInfo( + name="peer-one", + fqdn="peer.test", + display_name="Peer", + gateway_api_base="http://gateway.invalid/api/v1", + sync_service_url=PEER_SYNC_BASE, + ), + ], + ) + peer = caller_config.peers[0] + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url=PEER_SYNC_BASE, + ) as http: + result = await push_site_hello_to_peer(http, peer, caller_config) + + assert result["status"] == "registered" + assert app.state.peer_registry.get("testsite") is not None diff --git a/federation/tests/test_integration_pipeline.py b/federation/tests/test_integration_pipeline.py new file mode 100644 index 000000000..689483083 --- /dev/null +++ b/federation/tests/test_integration_pipeline.py @@ -0,0 +1,73 @@ +"""Integration: simulated Redis dispatch → outbound POST → peer webhook → OpenSearch.""" + +from __future__ import annotations + +import httpx +import pytest +from sds_federation.services.fed_index import doc_id +from sds_federation.services.local_events import dispatch_federation_redis_payload +from sds_federation.testing.sample_data import TEST_DATASET_UUID +from sds_federation.testing.sample_data import simulated_dataset_redis_payload + +from tests.conftest import PEER_SYNC_BASE + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_redis_simulation_end_to_end_indexes_on_peer( + test_site_config, + stub_dataset_resolver, + peer_webhook_stack, +) -> None: + recording_opensearch, asgi_transport = peer_webhook_stack + payload = simulated_dataset_redis_payload() + + async with httpx.AsyncClient( + transport=asgi_transport, base_url=PEER_SYNC_BASE + ) as http: + dispatched = await dispatch_federation_redis_payload( + http, + test_site_config, + payload, + resolve_asset=stub_dataset_resolver, + ) + + assert dispatched is True + assert len(recording_opensearch.index_calls) == 1 + assert recording_opensearch.index_calls[0]["id"] == doc_id( + "testsite", + TEST_DATASET_UUID, + ) + assert ( + recording_opensearch.index_calls[0]["body"]["name"] + == "Simulated public dataset" + ) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_redis_subscriber_path_parses_and_dispatches( + test_site_config, + stub_dataset_resolver, + peer_webhook_stack, +) -> None: + """Same bytes as Redis pub/sub would deliver (json.loads once).""" + import json + + from sds_federation.services.local_events import dispatch_federation_redis_payload + + recording_opensearch, asgi_transport = peer_webhook_stack + raw = json.dumps(simulated_dataset_redis_payload()) + + async with httpx.AsyncClient( + transport=asgi_transport, base_url=PEER_SYNC_BASE + ) as http: + ok = await dispatch_federation_redis_payload( + http, + test_site_config, + json.loads(raw), + resolve_asset=stub_dataset_resolver, + ) + + assert ok is True + assert len(recording_opensearch.index_calls) == 1 diff --git a/federation/tests/test_integration_webhooks.py b/federation/tests/test_integration_webhooks.py new file mode 100644 index 000000000..ce49b8850 --- /dev/null +++ b/federation/tests/test_integration_webhooks.py @@ -0,0 +1,176 @@ +"""Integration tests: FastAPI webhook routes + app state.""" + +from __future__ import annotations + +from datetime import UTC +from datetime import datetime +from typing import TYPE_CHECKING + +import httpx +import pytest +from sds_federation.schemas.webhooks import AssetTypeEnum +from sds_federation.schemas.webhooks import AssetUpdatedWebhook +from sds_federation.services.fed_index import FED_DATASETS_INDEX +from sds_federation.services.fed_index import FederatedAssetIndexer +from sds_federation.services.fed_index import doc_id +from sds_federation.testing.sample_data import TEST_DATASET_UUID +from sds_federation.testing.sample_data import sample_federated_dataset_doc + +from tests.conftest import API_PREFIX +from tests.conftest import build_webhook_app +from tests.conftest import make_peer_config + +if TYPE_CHECKING: + from tests.support.mock_opensearch import RecordingOpenSearch + + +def _dataset_webhook_payload(*, site_name: str = "testsite") -> dict: + asset = sample_federated_dataset_doc(site_name=site_name) + webhook = AssetUpdatedWebhook( + event_type="updated", + timestamp=datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC), + site_name=site_name, + asset=asset, + asset_type=AssetTypeEnum.DATASET, + ) + return webhook.model_dump(mode="json") + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_dataset_webhook_indexes_via_http( + recording_opensearch: RecordingOpenSearch, +) -> None: + config = make_peer_config() + app = build_webhook_app(config, FederatedAssetIndexer(recording_opensearch)) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + f"{API_PREFIX}/webhook/dataset-updated", + json=_dataset_webhook_payload(site_name="testsite"), + ) + + assert response.status_code == 200 + assert response.json() == {"status": "accepted"} + assert len(recording_opensearch.index_calls) == 1 + assert recording_opensearch.index_calls[0]["index"] == FED_DATASETS_INDEX + assert recording_opensearch.index_calls[0]["id"] == doc_id( + "testsite", + TEST_DATASET_UUID, + ) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_webhook_rejects_unknown_origin_site( + recording_opensearch: RecordingOpenSearch, +) -> None: + config = make_peer_config() + app = build_webhook_app(config, FederatedAssetIndexer(recording_opensearch)) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + f"{API_PREFIX}/webhook/dataset-updated", + json=_dataset_webhook_payload(site_name="unknown-site"), + ) + + assert response.status_code == 403 + assert recording_opensearch.index_calls == [] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_webhook_rejects_site_name_mismatch_on_asset( + recording_opensearch: RecordingOpenSearch, +) -> None: + config = make_peer_config() + app = build_webhook_app(config, FederatedAssetIndexer(recording_opensearch)) + body = _dataset_webhook_payload(site_name="testsite") + body["asset"]["site_name"] = "mismatch" + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post( + f"{API_PREFIX}/webhook/dataset-updated", + json=body, + ) + + assert response.status_code == 422 + assert recording_opensearch.index_calls == [] + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_site_hello_registers_known_peer( + recording_opensearch: RecordingOpenSearch, +) -> None: + config = make_peer_config() + app = build_webhook_app(config, FederatedAssetIndexer(recording_opensearch)) + body = { + "site_name": "testsite", + "fqdn": "localhost", + "display_name": "Originating test site", + "sync_service_url": "http://testsite.test/sync", + } + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post(f"{API_PREFIX}/webhook/site-hello", json=body) + + assert response.status_code == 200 + assert response.json() == {"status": "registered", "site_name": "testsite"} + assert app.state.peer_registry.get("testsite") is not None + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_site_hello_rejects_unknown_site( + recording_opensearch: RecordingOpenSearch, +) -> None: + config = make_peer_config() + app = build_webhook_app(config, FederatedAssetIndexer(recording_opensearch)) + body = { + "site_name": "unknown", + "fqdn": "evil.test", + "sync_service_url": "http://evil.test/sync", + } + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post(f"{API_PREFIX}/webhook/site-hello", json=body) + + assert response.status_code == 403 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_site_hello_rejects_self_registration( + recording_opensearch: RecordingOpenSearch, +) -> None: + config = make_peer_config() + app = build_webhook_app(config, FederatedAssetIndexer(recording_opensearch)) + body = { + "site_name": "peer-one", + "fqdn": "peer.test", + "sync_service_url": "http://peer-one.test/sync", + } + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://test", + ) as client: + response = await client.post(f"{API_PREFIX}/webhook/site-hello", json=body) + + assert response.status_code == 422 diff --git a/federation/tests/test_redis_event_pipeline.py b/federation/tests/test_redis_event_pipeline.py new file mode 100644 index 000000000..32bd3636e --- /dev/null +++ b/federation/tests/test_redis_event_pipeline.py @@ -0,0 +1,111 @@ +"""Isolated sync tests: simulated Redis payload → stub asset → peer webhook.""" + +from __future__ import annotations + +import json + +import httpx +import pytest +from sds_federation.services.local_events import dispatch_federation_redis_payload +from sds_federation.services.local_events import parse_redis_event_payload +from sds_federation.services.peer_sync import peer_webhook_url +from sds_federation.testing.sample_data import TEST_DATASET_UUID +from sds_federation.testing.sample_data import simulated_dataset_redis_payload + + +@pytest.mark.asyncio +async def test_parse_simulated_redis_payload() -> None: + data = simulated_dataset_redis_payload() + parsed = parse_redis_event_payload(data) + assert parsed is not None + asset_type, event_type, uuid, _ts = parsed + assert event_type == "updated" + assert uuid == TEST_DATASET_UUID + assert asset_type.value == "dataset" + + +@pytest.mark.asyncio +async def test_invalid_redis_payload_ignored() -> None: + assert parse_redis_event_payload({"item_type": "file"}) is None + assert parse_redis_event_payload({"item_type": "dataset"}) is None + + +@pytest.mark.asyncio +async def test_dispatch_resolves_uuid_and_posts_webhook_to_peer( + test_site_config, + stub_dataset_resolver, + peer_webhook_recorder, +) -> None: + recorded, transport = peer_webhook_recorder + payload = simulated_dataset_redis_payload() + + async with httpx.AsyncClient(transport=transport) as http: + dispatched = await dispatch_federation_redis_payload( + http, + test_site_config, + payload, + resolve_asset=stub_dataset_resolver, + ) + + assert dispatched is True + assert len(recorded) == 1 + req = recorded[0] + expected_url = peer_webhook_url( + test_site_config.peers[0], + "/webhook/dataset-updated", + ) + assert str(req.url) == expected_url + body = json.loads(req.content.decode()) + assert body["site_name"] == "testsite" + assert body["asset_type"] == "dataset" + assert body["asset"]["uuid"] == str(TEST_DATASET_UUID) + assert body["asset"]["name"] == "Simulated public dataset" + assert body["event_type"] == "updated" + + +@pytest.mark.asyncio +async def test_dispatch_deleted_skips_resolver_and_sends_tombstone( + test_site_config, + stub_dataset_resolver, + peer_webhook_recorder, +) -> None: + recorded, transport = peer_webhook_recorder + payload = simulated_dataset_redis_payload(event_type="deleted") + + async def fail_if_called(*_args, **_kwargs): + raise AssertionError("gateway/stub fetch must not run for deleted events") + + async with httpx.AsyncClient(transport=transport) as http: + await dispatch_federation_redis_payload( + http, + test_site_config, + payload, + resolve_asset=fail_if_called, + ) + + assert len(recorded) == 1 + body = json.loads(recorded[0].content.decode()) + assert body["event_type"] == "deleted" + assert body["asset"]["uuid"] == str(TEST_DATASET_UUID) + assert body["asset"]["name"] == "" + + +@pytest.mark.asyncio +async def test_dispatch_unknown_uuid_resolver_raises( + test_site_config, + stub_dataset_resolver, + peer_webhook_recorder, +) -> None: + _, transport = peer_webhook_recorder + payload = simulated_dataset_redis_payload( + uuid="00000000-0000-0000-0000-000000000099", + ) + + async with httpx.AsyncClient(transport=transport) as http: + with pytest.raises(KeyError, match="no stub document"): + await dispatch_federation_redis_payload( + http, + test_site_config, + payload, + resolve_asset=stub_dataset_resolver, + ) diff --git a/federation/tests/test_regression_indexer.py b/federation/tests/test_regression_indexer.py new file mode 100644 index 000000000..dc27dfe1c --- /dev/null +++ b/federation/tests/test_regression_indexer.py @@ -0,0 +1,114 @@ +"""Regression tests for FederatedAssetIndexer (OpenSearch write behavior).""" + +from __future__ import annotations + +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from typing import TYPE_CHECKING + +import pytest +from sds_federation.schemas.webhooks import AssetTypeEnum +from sds_federation.schemas.webhooks import FederationEventType +from sds_federation.services.fed_index import FED_DATASETS_INDEX +from sds_federation.services.fed_index import FederatedAssetIndexer +from sds_federation.services.fed_index import doc_id +from sds_federation.testing.sample_data import TEST_DATASET_UUID +from sds_federation.testing.sample_data import sample_federated_dataset_doc + +if TYPE_CHECKING: + from tests.support.mock_opensearch import RecordingOpenSearch + + +@pytest.mark.regression +def test_doc_id_format() -> None: + assert doc_id("crc", TEST_DATASET_UUID) == f"crc:{TEST_DATASET_UUID}" + + +@pytest.mark.regression +def test_indexer_writes_dataset_document( + recording_opensearch: RecordingOpenSearch, +) -> None: + indexer = FederatedAssetIndexer(recording_opensearch) + event_at = datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC) + asset = sample_federated_dataset_doc(site_name="testsite") + + indexer.apply_asset_event( + event_type=FederationEventType.UPDATED, + event_at=event_at, + site_name="testsite", + asset=asset, + asset_type=AssetTypeEnum.DATASET, + ) + + assert len(recording_opensearch.index_calls) == 1 + call = recording_opensearch.index_calls[0] + assert call["index"] == FED_DATASETS_INDEX + assert call["id"] == doc_id("testsite", TEST_DATASET_UUID) + assert call["body"]["is_federated_deleted"] is False + assert call["body"]["federation_event_at"] == event_at.isoformat() + assert call["body"]["name"] == "Simulated public dataset" + + +@pytest.mark.regression +def test_indexer_delete_uses_update_with_tombstone( + recording_opensearch: RecordingOpenSearch, +) -> None: + indexer = FederatedAssetIndexer(recording_opensearch) + event_at = datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC) + asset = sample_federated_dataset_doc(site_name="testsite") + + indexer.apply_asset_event( + event_type=FederationEventType.DELETED, + event_at=event_at, + site_name="testsite", + asset=asset, + asset_type=AssetTypeEnum.DATASET, + ) + + assert len(recording_opensearch.update_calls) == 1 + assert recording_opensearch.index_calls == [] + doc = recording_opensearch.update_calls[0]["body"]["doc"] + assert doc["is_federated_deleted"] is True + + +@pytest.mark.regression +def test_indexer_skips_stale_events(recording_opensearch: RecordingOpenSearch) -> None: + indexer = FederatedAssetIndexer(recording_opensearch) + asset = sample_federated_dataset_doc(site_name="testsite") + t1 = datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC) + t0 = t1 - timedelta(seconds=1) + + indexer.apply_asset_event( + event_type=FederationEventType.UPDATED, + event_at=t1, + site_name="testsite", + asset=asset, + asset_type=AssetTypeEnum.DATASET, + ) + indexer.apply_asset_event( + event_type=FederationEventType.UPDATED, + event_at=t0, + site_name="testsite", + asset=asset, + asset_type=AssetTypeEnum.DATASET, + ) + + assert len(recording_opensearch.index_calls) == 1 + + +@pytest.mark.regression +def test_indexer_rejects_site_name_mismatch( + recording_opensearch: RecordingOpenSearch, +) -> None: + indexer = FederatedAssetIndexer(recording_opensearch) + asset = sample_federated_dataset_doc(site_name="other-site") + + with pytest.raises(ValueError, match="site_name must match"): + indexer.apply_asset_event( + event_type=FederationEventType.UPDATED, + event_at=datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC), + site_name="testsite", + asset=asset, + asset_type=AssetTypeEnum.DATASET, + ) diff --git a/federation/tests/test_regression_schemas.py b/federation/tests/test_regression_schemas.py new file mode 100644 index 000000000..adcdec5ed --- /dev/null +++ b/federation/tests/test_regression_schemas.py @@ -0,0 +1,64 @@ +"""Regression: Pydantic webhook/export contracts and peer URL helpers.""" + +from __future__ import annotations + +from datetime import UTC +from datetime import datetime + +import pytest +from sds_federation.schemas.webhooks import AssetTypeEnum +from sds_federation.schemas.webhooks import AssetUpdatedWebhook +from sds_federation.schemas.webhooks import FederatedCaptureDoc +from sds_federation.schemas.webhooks import FederatedDatasetDoc +from sds_federation.services.peer_sync import peer_webhook_url +from sds_federation.testing.sample_data import TEST_CAPTURE_UUID +from sds_federation.testing.sample_data import TEST_DATASET_UUID +from sds_federation.testing.sample_data import sample_federated_capture_doc +from sds_federation.testing.sample_data import sample_federated_dataset_doc + + +@pytest.mark.regression +def test_federated_dataset_doc_rejects_unknown_fields() -> None: + base = sample_federated_dataset_doc().model_dump() + base["extra_gateway_field"] = "nope" + with pytest.raises(Exception): + FederatedDatasetDoc.model_validate(base) + + +@pytest.mark.regression +def test_asset_updated_webhook_round_trip_dataset() -> None: + asset = sample_federated_dataset_doc(site_name="crc") + payload = AssetUpdatedWebhook( + event_type="updated", + timestamp=datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC), + site_name="crc", + asset=asset, + asset_type=AssetTypeEnum.DATASET, + ) + restored = AssetUpdatedWebhook.model_validate(payload.model_dump(mode="json")) + assert restored.asset is not None + assert restored.asset.uuid == TEST_DATASET_UUID + + +@pytest.mark.regression +def test_asset_updated_webhook_round_trip_capture() -> None: + asset = sample_federated_capture_doc(site_name="crc") + payload = AssetUpdatedWebhook( + event_type="created", + timestamp=datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC), + site_name="crc", + asset=asset, + asset_type=AssetTypeEnum.CAPTURE, + ) + restored = AssetUpdatedWebhook.model_validate(payload.model_dump(mode="json")) + assert isinstance(restored.asset, FederatedCaptureDoc) + assert restored.asset.uuid == TEST_CAPTURE_UUID + + +@pytest.mark.regression +def test_peer_webhook_url_strips_trailing_slash() -> None: + class Peer: + sync_service_url = "https://example.test/sync/" + + url = peer_webhook_url(Peer(), "/webhook/dataset-updated") + assert url == "https://example.test/sync/api/v1/webhook/dataset-updated" From 99099835da0d63ea51164f5205bace82996dc6d6 Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 19 Jun 2026 13:11:07 -0400 Subject: [PATCH 04/11] create federation transport mesh to mock end-to-end peer app sync --- federation/tests/conftest.py | 80 ++++--- federation/tests/support/federation_mesh.py | 195 ++++++++++++++++++ .../tests/support/gateway_export_mock.py | 91 ++++++++ .../tests/support/mock_peer_registry.py | 16 ++ .../tests/test_integration_bootstrap.py | 6 +- federation/tests/test_integration_mesh.py | 105 ++++++++++ federation/tests/test_integration_webhooks.py | 14 +- 7 files changed, 453 insertions(+), 54 deletions(-) create mode 100644 federation/tests/support/federation_mesh.py create mode 100644 federation/tests/support/gateway_export_mock.py create mode 100644 federation/tests/support/mock_peer_registry.py create mode 100644 federation/tests/test_integration_mesh.py diff --git a/federation/tests/conftest.py b/federation/tests/conftest.py index 91771f71b..65e14159d 100644 --- a/federation/tests/conftest.py +++ b/federation/tests/conftest.py @@ -4,8 +4,6 @@ import pytest from fastapi import FastAPI from sds_federation.models import FederationConfig -from sds_federation.models import PeerInfo -from sds_federation.models import SiteInfo from sds_federation.routes.webhooks import webhooks_router from sds_federation.schemas.webhooks import AssetTypeEnum from sds_federation.schemas.webhooks import FederatedDatasetDoc @@ -15,44 +13,46 @@ from sds_federation.testing.sample_data import TEST_DATASET_UUID from sds_federation.testing.sample_data import sample_federated_dataset_doc +from tests.support.federation_mesh import PEER_ONE_SYNC_ORIGIN +from tests.support.federation_mesh import build_federation_mesh +from tests.support.federation_mesh import close_mesh +from tests.support.federation_mesh import peer_one_config +from tests.support.federation_mesh import testsite_config from tests.support.mock_opensearch import RecordingOpenSearch +from tests.support.mock_peer_registry import RecordingPeerRegistry API_PREFIX = "/api/v1" -PEER_SYNC_BASE = "http://peer-sync.test" +SYNC_API_PREFIX = f"/sync{API_PREFIX}" +PEER_SYNC_BASE = PEER_ONE_SYNC_ORIGIN def make_peer_config(*, site_name: str = "peer-one") -> FederationConfig: """Receiver sync: allows webhooks whose site_name is listed in peers.""" - return FederationConfig( - site=SiteInfo( - name=site_name, - fqdn="peer.test", - display_name="Peer Site", - ), - gateway_api_base="http://gateway.invalid/api/v1", - sync_service_url="http://peer-one.test/sync", - peers=[ - PeerInfo( - name="testsite", - fqdn="localhost", - display_name="Originating test site", - gateway_api_base="http://gateway.invalid/api/v1", - sync_service_url="http://unused/", - ), - ], - ) + config = peer_one_config() + if site_name != "peer-one": + return config.model_copy( + update={ + "site": config.site.model_copy(update={"name": site_name}), + }, + ) + return config def build_webhook_app( config: FederationConfig, indexer: FederatedAssetIndexer, + registry: PeerRegistry | RecordingPeerRegistry | None = None, ) -> FastAPI: - app = FastAPI() - app.state.config = config - app.state.fed_indexer = indexer - app.state.peer_registry = PeerRegistry() - app.include_router(webhooks_router, prefix=API_PREFIX) - return app + reg = registry or PeerRegistry() + sync_app = FastAPI() + sync_app.state.config = config + sync_app.state.fed_indexer = indexer + sync_app.state.peer_registry = reg + sync_app.include_router(webhooks_router, prefix=API_PREFIX) + root = FastAPI() + root.mount("/sync", sync_app) + root.state.peer_registry = sync_app.state.peer_registry + return root @pytest.fixture @@ -62,24 +62,7 @@ def recording_opensearch() -> RecordingOpenSearch: @pytest.fixture def test_site_config() -> FederationConfig: - return FederationConfig( - site=SiteInfo( - name="testsite", - fqdn="localhost", - display_name="Test Site", - ), - gateway_api_base="http://gateway.invalid/api/v1", - sync_service_url="http://testsite.test/sync", - peers=[ - PeerInfo( - name="peer-one", - fqdn="peer.test", - display_name="Peer One", - gateway_api_base="http://peer-gateway.invalid/api/v1", - sync_service_url=PEER_SYNC_BASE, - ), - ], - ) + return testsite_config() @pytest.fixture @@ -110,6 +93,13 @@ async def resolve( return resolve +@pytest.fixture +async def two_site_mesh(): + mesh = build_federation_mesh() + yield mesh + await close_mesh(mesh) + + @pytest.fixture def peer_webhook_recorder(): """httpx transport that records outbound peer webhook POSTs.""" diff --git a/federation/tests/support/federation_mesh.py b/federation/tests/support/federation_mesh.py new file mode 100644 index 000000000..d46cad085 --- /dev/null +++ b/federation/tests/support/federation_mesh.py @@ -0,0 +1,195 @@ +"""In-process multi-site federation mesh for integration tests.""" + +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field + +import httpx +from fastapi import FastAPI + +from sds_federation.models import FederationConfig +from sds_federation.models import PeerInfo +from sds_federation.models import SiteInfo +from sds_federation.routes.webhooks import webhooks_router +from sds_federation.services.fed_index import FederatedAssetIndexer +from tests.support.gateway_export_mock import GatewayExportCatalog +from tests.support.gateway_export_mock import handle_gateway_export_request +from tests.support.mock_opensearch import RecordingOpenSearch +from tests.support.mock_peer_registry import RecordingPeerRegistry + +API_PREFIX = "/api/v1" + +TESTSITE_SYNC_ORIGIN = "http://testsite.test" +PEER_ONE_SYNC_ORIGIN = "http://peer-one.test" +TESTSITE_GATEWAY_HOST = "testsite-gateway.test" +PEER_ONE_GATEWAY_HOST = "peer-one-gateway.test" + + +def testsite_config() -> FederationConfig: + return FederationConfig( + site=SiteInfo( + name="testsite", + fqdn="localhost", + display_name="Test Site", + ), + gateway_api_base=f"http://{TESTSITE_GATEWAY_HOST}/api/v1", + sync_service_url=f"{TESTSITE_SYNC_ORIGIN}/sync", + peers=[ + PeerInfo( + name="peer-one", + fqdn="peer.test", + display_name="Peer One", + gateway_api_base=f"http://{PEER_ONE_GATEWAY_HOST}/api/v1", + sync_service_url=f"{PEER_ONE_SYNC_ORIGIN}/sync", + ), + ], + ) + + +def peer_one_config() -> FederationConfig: + return FederationConfig( + site=SiteInfo( + name="peer-one", + fqdn="peer.test", + display_name="Peer Site", + ), + gateway_api_base=f"http://{PEER_ONE_GATEWAY_HOST}/api/v1", + sync_service_url=f"{PEER_ONE_SYNC_ORIGIN}/sync", + peers=[ + PeerInfo( + name="testsite", + fqdn="localhost", + display_name="Test Site", + gateway_api_base=f"http://{TESTSITE_GATEWAY_HOST}/api/v1", + sync_service_url=f"{TESTSITE_SYNC_ORIGIN}/sync", + ), + ], + ) + + +def build_sync_app( + config: FederationConfig, + indexer: FederatedAssetIndexer, + registry: RecordingPeerRegistry | None = None, +) -> FastAPI: + """Outer app mounts sync routes at /sync (matches production Traefik path).""" + sync_app = FastAPI() + sync_app.state.config = config + sync_app.state.fed_indexer = indexer + sync_app.state.peer_registry = registry or RecordingPeerRegistry() + sync_app.include_router(webhooks_router, prefix=API_PREFIX) + root = FastAPI() + root.mount("/sync", sync_app) + return root + + +@dataclass +class SyncSite: + name: str + config: FederationConfig + app: FastAPI + opensearch: RecordingOpenSearch + registry: RecordingPeerRegistry + + +@dataclass +class FederationMesh: + sites: dict[str, SyncSite] + http: httpx.AsyncClient + gateway_catalog: GatewayExportCatalog + recorded_webhooks: list[httpx.Request] = field(default_factory=list) + + def site(self, name: str) -> SyncSite: + return self.sites[name] + + +class FederationMeshTransport(httpx.AsyncBaseTransport): + """Route sync hosts to ASGI apps; gateway hosts to export catalog.""" + + def __init__( + self, + sync_apps_by_host: dict[str, FastAPI], + gateway_catalog: GatewayExportCatalog, + recorded_webhooks: list[httpx.Request], + ) -> None: + self._sync_apps = sync_apps_by_host + self._gateway_catalog = gateway_catalog + self._recorded_webhooks = recorded_webhooks + + async def handle_async_request( + self, + request: httpx.Request, + ) -> httpx.Response: + if request.method == "POST" and "/webhook/" in request.url.path: + self._recorded_webhooks.append(request) + + export_response = handle_gateway_export_request( + request, + self._gateway_catalog, + ) + if export_response is not None: + return export_response + + host = request.url.host + if not host: + return httpx.Response(400, json={"detail": "missing host"}) + + app = self._sync_apps.get(host) + if app is None: + return httpx.Response( + 404, + json={"detail": f"no sync app registered for host {host!r}"}, + ) + + transport = httpx.ASGITransport(app=app) + async with transport as asgi: + return await asgi.handle_async_request(request) + + +def build_federation_mesh( + *, + configs: dict[str, FederationConfig] | None = None, +) -> FederationMesh: + if configs is None: + configs = { + "testsite": testsite_config(), + "peer-one": peer_one_config(), + } + + recorded: list[httpx.Request] = [] + catalog = GatewayExportCatalog() + sites: dict[str, SyncSite] = {} + sync_apps: dict[str, FastAPI] = {} + + for name, config in configs.items(): + opensearch = RecordingOpenSearch() + registry = RecordingPeerRegistry() + indexer = FederatedAssetIndexer(opensearch) + app = build_sync_app(config, indexer, registry) + sites[name] = SyncSite( + name=name, + config=config, + app=app, + opensearch=opensearch, + registry=registry, + ) + sync_host = httpx.URL(str(config.sync_service_url)).host + if sync_host: + sync_apps[sync_host] = app + + transport = FederationMeshTransport(sync_apps, catalog, recorded) + http = httpx.AsyncClient( + transport=transport, + timeout=httpx.Timeout(10.0), + ) + return FederationMesh( + sites=sites, + http=http, + gateway_catalog=catalog, + recorded_webhooks=recorded, + ) + + +async def close_mesh(mesh: FederationMesh) -> None: + await mesh.http.aclose() diff --git a/federation/tests/support/gateway_export_mock.py b/federation/tests/support/gateway_export_mock.py new file mode 100644 index 000000000..3bafb736f --- /dev/null +++ b/federation/tests/support/gateway_export_mock.py @@ -0,0 +1,91 @@ +"""Mock gateway federation export HTTP responses keyed by gateway host.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from dataclasses import field +from urllib.parse import urlparse + +import httpx + +from sds_federation.schemas.webhooks import FederatedCaptureDoc +from sds_federation.schemas.webhooks import FederatedDatasetDoc + + +@dataclass +class GatewayExportCatalog: + datasets_by_host: dict[str, list[FederatedDatasetDoc]] = field( + default_factory=dict, + ) + captures_by_host: dict[str, list[FederatedCaptureDoc]] = field( + default_factory=dict, + ) + + def set_datasets( + self, + gateway_host: str, + docs: list[FederatedDatasetDoc], + ) -> None: + self.datasets_by_host[gateway_host] = docs + + def set_captures( + self, + gateway_host: str, + docs: list[FederatedCaptureDoc], + ) -> None: + self.captures_by_host[gateway_host] = docs + + +def gateway_host_from_base(gateway_api_base: str) -> str: + parsed = urlparse(str(gateway_api_base)) + return parsed.hostname or "" + + +def handle_gateway_export_request( + request: httpx.Request, + catalog: GatewayExportCatalog, +) -> httpx.Response | None: + """Return a response if this request targets a mocked export URL.""" + if request.method != "GET": + return None + host = request.url.host + if not host: + return None + path = request.url.path + if "/federation/export/datasets/" not in path and "/federation/export/captures/" not in path: + return None + + if "/federation/export/datasets" in path and path.rstrip("/").endswith( + "datasets", + ): + docs = catalog.datasets_by_host.get(host, []) + return httpx.Response( + 200, + content=json.dumps([d.model_dump(mode="json") for d in docs]), + ) + + if "/federation/export/datasets/" in path: + uuid_part = path.rstrip("/").split("/")[-1] + for doc in catalog.datasets_by_host.get(host, []): + if str(doc.uuid) == uuid_part: + return httpx.Response(200, json=doc.model_dump(mode="json")) + return httpx.Response(404, json={"detail": "not found"}) + + if "/federation/export/captures" in path and path.rstrip("/").endswith( + "captures", + ): + docs = catalog.captures_by_host.get(host, []) + return httpx.Response( + 200, + content=json.dumps([d.model_dump(mode="json") for d in docs]), + ) + + if "/federation/export/captures/" in path: + uuid_part = path.rstrip("/").split("/")[-1] + for doc in catalog.captures_by_host.get(host, []): + if str(doc.uuid) == uuid_part: + return httpx.Response(200, json=doc.model_dump(mode="json")) + return httpx.Response(404, json={"detail": "not found"}) + + return None diff --git a/federation/tests/support/mock_peer_registry.py b/federation/tests/support/mock_peer_registry.py new file mode 100644 index 000000000..e036188bd --- /dev/null +++ b/federation/tests/support/mock_peer_registry.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from sds_federation.schemas.webhooks import SiteHelloWebhook +from sds_federation.services.peer_registry import PeerRegistry + + +class RecordingPeerRegistry(PeerRegistry): + """PeerRegistry that records every site-hello for test assertions.""" + + def __init__(self) -> None: + super().__init__() + self.registration_events: list[SiteHelloWebhook] = [] + + def register(self, hello: SiteHelloWebhook) -> None: + self.registration_events.append(hello) + super().register(hello) diff --git a/federation/tests/test_integration_bootstrap.py b/federation/tests/test_integration_bootstrap.py index 7296760e2..ba1188abf 100644 --- a/federation/tests/test_integration_bootstrap.py +++ b/federation/tests/test_integration_bootstrap.py @@ -74,6 +74,8 @@ async def test_push_site_hello_to_in_process_peer( from tests.conftest import PEER_SYNC_BASE from tests.conftest import build_webhook_app + peer_sync_url = f"{PEER_SYNC_BASE}/sync" + peer_config = make_peer_config() app = build_webhook_app(peer_config, FederatedAssetIndexer(recording_opensearch)) caller_config = FederationConfig( @@ -90,7 +92,7 @@ async def test_push_site_hello_to_in_process_peer( fqdn="peer.test", display_name="Peer", gateway_api_base="http://gateway.invalid/api/v1", - sync_service_url=PEER_SYNC_BASE, + sync_service_url=peer_sync_url, ), ], ) @@ -98,7 +100,7 @@ async def test_push_site_hello_to_in_process_peer( async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), - base_url=PEER_SYNC_BASE, + base_url=peer_sync_url, ) as http: result = await push_site_hello_to_peer(http, peer, caller_config) diff --git a/federation/tests/test_integration_mesh.py b/federation/tests/test_integration_mesh.py new file mode 100644 index 000000000..881069e46 --- /dev/null +++ b/federation/tests/test_integration_mesh.py @@ -0,0 +1,105 @@ +"""Integration tests using the two-site in-process federation mesh.""" + +from __future__ import annotations + +import json +from datetime import UTC +from datetime import datetime + +import pytest +from sds_federation.schemas.webhooks import AssetUpdatedWebhook +from sds_federation.services.bootstrap import bootstrap_gateway_exports +from sds_federation.services.bootstrap import push_site_hello_to_peer +from sds_federation.services.bootstrap import register_with_peers +from sds_federation.services.fed_index import FederatedAssetIndexer +from sds_federation.services.fed_index import doc_id +from sds_federation.services.local_events import dispatch_federation_redis_payload +from sds_federation.testing.sample_data import TEST_DATASET_UUID +from sds_federation.testing.sample_data import sample_federated_dataset_doc +from sds_federation.testing.sample_data import simulated_dataset_redis_payload + +from tests.support.federation_mesh import FederationMesh +from tests.support.federation_mesh import TESTSITE_GATEWAY_HOST + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_mesh_dispatches_redis_event_to_peer_opensearch( + two_site_mesh: FederationMesh, + test_site_config, + stub_dataset_resolver, +) -> None: + mesh = two_site_mesh + payload = simulated_dataset_redis_payload() + ok = await dispatch_federation_redis_payload( + mesh.http, + test_site_config, + payload, + resolve_asset=stub_dataset_resolver, + ) + assert ok is True + peer = mesh.site("peer-one") + assert len(peer.opensearch.index_calls) == 1 + assert peer.opensearch.index_calls[0]["id"] == doc_id( + "testsite", + TEST_DATASET_UUID, + ) + assert len(mesh.recorded_webhooks) == 1 + body = json.loads(mesh.recorded_webhooks[0].content.decode()) + webhook = AssetUpdatedWebhook.model_validate(body) + assert webhook.site_name == "testsite" + assert webhook.asset is not None + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_mesh_site_hello_registers_on_peer_registry( + two_site_mesh: FederationMesh, +) -> None: + mesh = two_site_mesh + caller = mesh.site("testsite") + peer = mesh.site("peer-one") + result = await push_site_hello_to_peer( + mesh.http, + caller.config.peers[0], + caller.config, + ) + assert result["status"] == "registered" + assert peer.registry.get("testsite") is not None + assert len(peer.registry.registration_events) == 1 + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_mesh_register_with_peers_both_directions( + two_site_mesh: FederationMesh, +) -> None: + mesh = two_site_mesh + await register_with_peers(mesh.http, mesh.site("testsite").config) + await register_with_peers(mesh.http, mesh.site("peer-one").config) + assert mesh.site("peer-one").registry.get("testsite") is not None + assert mesh.site("testsite").registry.get("peer-one") is not None + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_mesh_bootstrap_pulls_gateway_export_catalog( + two_site_mesh: FederationMesh, +) -> None: + mesh = two_site_mesh + doc = sample_federated_dataset_doc(site_name="testsite") + mesh.gateway_catalog.set_datasets(TESTSITE_GATEWAY_HOST, [doc]) + peer = mesh.site("peer-one") + indexer = FederatedAssetIndexer(peer.opensearch) + event_at = datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC) + count = await bootstrap_gateway_exports( + mesh.http, + peer.config.peers[0], + indexer, + event_at=event_at, + ) + assert count == 1 + assert peer.opensearch.index_calls[0]["id"] == doc_id( + "testsite", + TEST_DATASET_UUID, + ) diff --git a/federation/tests/test_integration_webhooks.py b/federation/tests/test_integration_webhooks.py index ce49b8850..0507f2c6d 100644 --- a/federation/tests/test_integration_webhooks.py +++ b/federation/tests/test_integration_webhooks.py @@ -16,7 +16,7 @@ from sds_federation.testing.sample_data import TEST_DATASET_UUID from sds_federation.testing.sample_data import sample_federated_dataset_doc -from tests.conftest import API_PREFIX +from tests.conftest import SYNC_API_PREFIX from tests.conftest import build_webhook_app from tests.conftest import make_peer_config @@ -49,7 +49,7 @@ async def test_dataset_webhook_indexes_via_http( base_url="http://test", ) as client: response = await client.post( - f"{API_PREFIX}/webhook/dataset-updated", + f"{SYNC_API_PREFIX}/webhook/dataset-updated", json=_dataset_webhook_payload(site_name="testsite"), ) @@ -76,7 +76,7 @@ async def test_webhook_rejects_unknown_origin_site( base_url="http://test", ) as client: response = await client.post( - f"{API_PREFIX}/webhook/dataset-updated", + f"{SYNC_API_PREFIX}/webhook/dataset-updated", json=_dataset_webhook_payload(site_name="unknown-site"), ) @@ -99,7 +99,7 @@ async def test_webhook_rejects_site_name_mismatch_on_asset( base_url="http://test", ) as client: response = await client.post( - f"{API_PREFIX}/webhook/dataset-updated", + f"{SYNC_API_PREFIX}/webhook/dataset-updated", json=body, ) @@ -125,7 +125,7 @@ async def test_site_hello_registers_known_peer( transport=httpx.ASGITransport(app=app), base_url="http://test", ) as client: - response = await client.post(f"{API_PREFIX}/webhook/site-hello", json=body) + response = await client.post(f"{SYNC_API_PREFIX}/webhook/site-hello", json=body) assert response.status_code == 200 assert response.json() == {"status": "registered", "site_name": "testsite"} @@ -149,7 +149,7 @@ async def test_site_hello_rejects_unknown_site( transport=httpx.ASGITransport(app=app), base_url="http://test", ) as client: - response = await client.post(f"{API_PREFIX}/webhook/site-hello", json=body) + response = await client.post(f"{SYNC_API_PREFIX}/webhook/site-hello", json=body) assert response.status_code == 403 @@ -171,6 +171,6 @@ async def test_site_hello_rejects_self_registration( transport=httpx.ASGITransport(app=app), base_url="http://test", ) as client: - response = await client.post(f"{API_PREFIX}/webhook/site-hello", json=body) + response = await client.post(f"{SYNC_API_PREFIX}/webhook/site-hello", json=body) assert response.status_code == 422 From ec5bc63f572447b377565072b02e1a5b66a028de Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 19 Jun 2026 13:14:21 -0400 Subject: [PATCH 05/11] remove deprecated vars from tests --- federation/tests/test_integration_webhooks.py | 5 +++-- federation/tests/test_regression_indexer.py | 3 +-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/federation/tests/test_integration_webhooks.py b/federation/tests/test_integration_webhooks.py index 0507f2c6d..ed9b4101c 100644 --- a/federation/tests/test_integration_webhooks.py +++ b/federation/tests/test_integration_webhooks.py @@ -10,7 +10,6 @@ import pytest from sds_federation.schemas.webhooks import AssetTypeEnum from sds_federation.schemas.webhooks import AssetUpdatedWebhook -from sds_federation.services.fed_index import FED_DATASETS_INDEX from sds_federation.services.fed_index import FederatedAssetIndexer from sds_federation.services.fed_index import doc_id from sds_federation.testing.sample_data import TEST_DATASET_UUID @@ -56,7 +55,9 @@ async def test_dataset_webhook_indexes_via_http( assert response.status_code == 200 assert response.json() == {"status": "accepted"} assert len(recording_opensearch.index_calls) == 1 - assert recording_opensearch.index_calls[0]["index"] == FED_DATASETS_INDEX + assert ( + recording_opensearch.index_calls[0]["index"] == AssetTypeEnum.DATASET.index_name + ) assert recording_opensearch.index_calls[0]["id"] == doc_id( "testsite", TEST_DATASET_UUID, diff --git a/federation/tests/test_regression_indexer.py b/federation/tests/test_regression_indexer.py index dc27dfe1c..a2d7022d5 100644 --- a/federation/tests/test_regression_indexer.py +++ b/federation/tests/test_regression_indexer.py @@ -10,7 +10,6 @@ import pytest from sds_federation.schemas.webhooks import AssetTypeEnum from sds_federation.schemas.webhooks import FederationEventType -from sds_federation.services.fed_index import FED_DATASETS_INDEX from sds_federation.services.fed_index import FederatedAssetIndexer from sds_federation.services.fed_index import doc_id from sds_federation.testing.sample_data import TEST_DATASET_UUID @@ -43,7 +42,7 @@ def test_indexer_writes_dataset_document( assert len(recording_opensearch.index_calls) == 1 call = recording_opensearch.index_calls[0] - assert call["index"] == FED_DATASETS_INDEX + assert call["index"] == AssetTypeEnum.DATASET.index_name assert call["id"] == doc_id("testsite", TEST_DATASET_UUID) assert call["body"]["is_federated_deleted"] is False assert call["body"]["federation_event_at"] == event_at.isoformat() From db630b0f45f115cbe47cdd8ab3a633e168f8cb42 Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 25 Jun 2026 15:29:07 -0400 Subject: [PATCH 06/11] integrate pyrefly --- .pre-commit-config.yaml | 6 ++++++ federation/justfile | 3 ++- federation/pyproject.toml | 12 +++++++++++ federation/sds_federation/schemas/webhooks.py | 14 +++++++------ .../sds_federation/services/bootstrap.py | 5 +++-- federation/uv.lock | 21 +++++++++++++++++++ 6 files changed, 52 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b8862759..25bd1a991 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -122,6 +122,12 @@ repos: language: system pass_filenames: false files: ^gateway/sds_gateway/static/js/.*\.js$ + - id: pyrefly-federation + name: pyrefly (federation) + entry: bash -c "cd federation && uv run pyrefly check -c pyproject.toml" + language: system + pass_filenames: false + files: ^federation/.*\.py$ # sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date ci: diff --git a/federation/justfile b/federation/justfile index 91618b494..d300e8b41 100644 --- a/federation/justfile +++ b/federation/justfile @@ -29,11 +29,12 @@ test +args='': test-q: uv run pytest -q -# Run federation ruff hooks from repo root (requires gateway dev deps / pre-commit install) +# Run federation pre-commit hooks from repo root (requires gateway dev deps / pre-commit install) [group('qa')] pre-commit +args='': cd .. && uv run --directory gateway --extra local pre-commit run ruff-check-federation --all-files {{ args }} cd .. && uv run --directory gateway --extra local pre-commit run ruff-format-federation --all-files {{ args }} + cd .. && uv run --directory gateway --extra local pre-commit run pyrefly-federation --all-files {{ args }} # dev PKI for mTLS experiments (see docs/mtls-certificates.md) [group('setup')] diff --git a/federation/pyproject.toml b/federation/pyproject.toml index c923eb36d..c001e57da 100644 --- a/federation/pyproject.toml +++ b/federation/pyproject.toml @@ -23,6 +23,7 @@ packages = ["sds_federation"] [project.optional-dependencies] dev = [ "deptry>=0.24.0", + "pyrefly>=0.42.1", "pytest>=8.3.0", "pytest-asyncio>=0.25.0", "ruff>=0.15.0", @@ -34,6 +35,7 @@ dev = [ [tool.deptry.per_rule_ignores] DEP002 = [ "deptry", + "pyrefly", "pytest", "pytest-asyncio", "ruff", @@ -145,6 +147,16 @@ dev = [ quote-style = "double" skip-magic-trailing-comma = false +[tool.pyrefly] + project-excludes = ["**/.[!/.]*", "**/tests", "tests/", "**/scripts"] + project-includes = ["sds_federation"] + search-path = ["sds_federation"] + + python-platform = "linux" + python-version = "3.13" + + ignore-errors-in-generated-code = true + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/federation/sds_federation/schemas/webhooks.py b/federation/sds_federation/schemas/webhooks.py index 7eb4f482d..eec709b54 100644 --- a/federation/sds_federation/schemas/webhooks.py +++ b/federation/sds_federation/schemas/webhooks.py @@ -27,12 +27,6 @@ def export_path(self) -> str: def webhook_path(self) -> str: return f"/webhook/{self.value}-updated" - @property - def doc_class(self) -> type[BaseModel]: - if self == AssetTypeEnum.DATASET: - return FederatedDatasetDoc - return FederatedCaptureDoc - @property def index_name(self) -> str: return f"fed-{self.value}s" @@ -92,6 +86,14 @@ class FederatedCaptureDoc(BaseModel): dataset_ids: list[str] = Field(default_factory=list) +def asset_doc_class( + asset_type: AssetTypeEnum, +) -> type[FederatedDatasetDoc] | type[FederatedCaptureDoc]: + if asset_type is AssetTypeEnum.DATASET: + return FederatedDatasetDoc + return FederatedCaptureDoc + + class AssetUpdatedWebhook(BaseModel): event_type: FederationEventType timestamp: datetime diff --git a/federation/sds_federation/services/bootstrap.py b/federation/sds_federation/services/bootstrap.py index cd9fa15f9..0ad8c6773 100644 --- a/federation/sds_federation/services/bootstrap.py +++ b/federation/sds_federation/services/bootstrap.py @@ -17,6 +17,7 @@ from sds_federation.schemas.webhooks import FederatedDatasetDoc from sds_federation.schemas.webhooks import FederationEventType from sds_federation.schemas.webhooks import SiteHelloWebhook +from sds_federation.schemas.webhooks import asset_doc_class from sds_federation.services.peer_sync import peer_webhook_url if TYPE_CHECKING: @@ -70,7 +71,7 @@ async def fetch_peer_export_list( if not isinstance(data, list): msg = f"expected list from {url}, got {type(data).__name__}" raise TypeError(msg) - doc_class = asset_type.doc_class + doc_class = asset_doc_class(asset_type) return [doc_class.model_validate(item) for item in data] @@ -207,7 +208,7 @@ async def run_bootstrap( ) -> None: when = event_at or datetime.now(UTC) local_count = await bootstrap_local_site(http, config, indexer, event_at=when) - peer_count = await bootstrap_all_peers(http, config, indexer, event_at=when) + peer_count = await bootstrap_all_peers(config, http, indexer, event_at=when) logger.info( "Bootstrap indexed {} local + {} peer export documents", local_count, diff --git a/federation/uv.lock b/federation/uv.lock index b44760f59..3121ca92b 100644 --- a/federation/uv.lock +++ b/federation/uv.lock @@ -350,6 +350,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyrefly" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/20/976165fa4b1517a1a92f393b3f4d4badabfff1165eff09d4cd4908428183/pyrefly-1.1.1.tar.gz", hash = "sha256:6deda959f8603a7dbdf112c48983e2275b2903cf33c8c739ed65d7e71a4fd520", size = 5880491, upload-time = "2026-06-18T23:45:43.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/d6/02ba666018c6a1cb4ddfa2db98ada721adddd374db5c29ba47a0bf2637fa/pyrefly-1.1.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f4b8595f91885bc8b5e3c282ab68d1df21201668a84e6508b1e15f2feec0bb8d", size = 13631867, upload-time = "2026-06-18T23:45:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/7a3457dbbddb513a83cf4fe527d5d5ebda5201a1010ad2a6034030e3e358/pyrefly-1.1.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6b238e1362622d47a6eb5af704fd8b613c94e8c303386efd6350e3da59fecc8", size = 13075304, upload-time = "2026-06-18T23:45:16.865Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/70f4b3f42d58ed686a80df31e04eca54d88036cea4f9b96195c64ad0b2b5/pyrefly-1.1.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b50d4510e4f8aaea79e2c4b343a4d7a060c9451c0b2aa9bfe10d7ca1ef33d68d", size = 13446966, upload-time = "2026-06-18T23:45:19.644Z" }, + { url = "https://files.pythonhosted.org/packages/3c/53/12a19bd6c7af985bcbc13c6910d0f9f6684069ead2282a5c08c2bfbb5d03/pyrefly-1.1.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f330cf039ef3da3b910c84f3a7e431f0cf8d0c1d2dad26491d6cadf3c7cd4759", size = 14449222, upload-time = "2026-06-18T23:45:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/93/f0/e55c48a50076fc0f9ecf4bdedec50456db383e01162f5e2121f8468be071/pyrefly-1.1.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6342d87c52b04f72156da04f554c4d57f3616f2b32d1763969efb22d05a1407", size = 14472947, upload-time = "2026-06-18T23:45:24.858Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e7/30e085b31fed978ecb675bdbb54df566673ab550469e5af2d350f6af0be6/pyrefly-1.1.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c08b814ad03175e9cf47111390537161828b472044c39ab3320252b3ac6b2edd", size = 13975252, upload-time = "2026-06-18T23:45:27.247Z" }, + { url = "https://files.pythonhosted.org/packages/47/58/49c3e67641133d3fe5d8d9a660dc0826c6c37ca197d86cad05fa7dd8bfd6/pyrefly-1.1.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d50cad97f19fc893b04deff7239626cffff5dd27ffb29b7d303a1b770247b208", size = 13471780, upload-time = "2026-06-18T23:45:29.775Z" }, + { url = "https://files.pythonhosted.org/packages/71/1e/65a7ba8355e2c39d8331832905fb74dcc85fc122a3f1dfd6dbf2a88907ad/pyrefly-1.1.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2150b450ee6a6bcbe69b2d45d9a4ebc934a609e1abcf65e490433f38eb873d84", size = 13989306, upload-time = "2026-06-18T23:45:32.576Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/b7ee1ab2392c36945738246fba7524439810befa3cfcc03cb6157567fc10/pyrefly-1.1.1-py3-none-win32.whl", hash = "sha256:5ffd8a8ed62fe4e6bf0afe1837d1bad149bb3b9f80e928ef248c96b836db3742", size = 12608469, upload-time = "2026-06-18T23:45:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/a0f5b52934bf80e9c7eff08222e7caf318287b9aef76acb8d9ac5740581b/pyrefly-1.1.1-py3-none-win_amd64.whl", hash = "sha256:4e0430f3ef69c8ac73505fd6584db70ed504665a9f0816fef7f723de510f26cb", size = 13502172, upload-time = "2026-06-18T23:45:38.375Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/4c6bcb3d456835f51445d3662a428f56c3ea5643ec798c577030ae34298c/pyrefly-1.1.1-py3-none-win_arm64.whl", hash = "sha256:83baf0db71e172665db1fca0ced50b8f7773f5192ca57e8ac6773a772b6d2fc5", size = 12895979, upload-time = "2026-06-18T23:45:41.026Z" }, +] + [[package]] name = "pytest" version = "9.1.0" @@ -495,6 +514,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "deptry" }, + { name = "pyrefly" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -508,6 +528,7 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7.2" }, { name = "opensearch-py", specifier = ">=2.8.0" }, { name = "pydantic", specifier = ">=2.11.0" }, + { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=0.42.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25.0" }, { name = "redis", specifier = ">=5.2.1" }, From 87472a3db90bc259bc9886893119be83ed3ee9ce Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 26 Jun 2026 12:02:08 -0400 Subject: [PATCH 07/11] build out health check stub to check redis/opensearch status and config setup --- federation/sds_federation/main.py | 2 + federation/sds_federation/routes/health.py | 18 ++- .../sds_federation/services/operational.py | 138 ++++++++++++++++ federation/tests/test_operational.py | 151 ++++++++++++++++++ 4 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 federation/sds_federation/services/operational.py create mode 100644 federation/tests/test_operational.py diff --git a/federation/sds_federation/main.py b/federation/sds_federation/main.py index 6ffffc79e..6ab83d3af 100644 --- a/federation/sds_federation/main.py +++ b/federation/sds_federation/main.py @@ -38,6 +38,7 @@ async def lifespan(app: FastAPI): os_client = OpenSearch(hosts=[{"host": os_host, "port": int(os_port)}]) app.state.config = config app.state.http = http + app.state.opensearch_client = os_client app.state.fed_indexer = FederatedAssetIndexer(os_client) app.state.peer_registry = PeerRegistry() @@ -61,6 +62,7 @@ async def lifespan(app: FastAPI): sub_task = asyncio.create_task( run_federation_subscriber(redis_url, http, config, stop), ) + app.state.subscriber_task = sub_task yield diff --git a/federation/sds_federation/routes/health.py b/federation/sds_federation/routes/health.py index 5d1a2df18..5fd4bc372 100644 --- a/federation/sds_federation/routes/health.py +++ b/federation/sds_federation/routes/health.py @@ -1,9 +1,21 @@ from fastapi import APIRouter +from fastapi import Request +from fastapi.responses import JSONResponse + +from sds_federation.services.operational import evaluate_operational health_router = APIRouter() @health_router.get("/health") -async def health(): - """Check the health of the federation""" - return {"message": "OK"} +async def health(request: Request) -> JSONResponse: + """Operational status for gateway probes and container healthchecks.""" + state = request.app.state + operational, body = await evaluate_operational( + config=getattr(state, "config", None), + http=getattr(state, "http", None), + opensearch=getattr(state, "opensearch_client", None), + subscriber_task=getattr(state, "subscriber_task", None), + ) + status_code = 200 if operational else 503 + return JSONResponse(status_code=status_code, content=body) diff --git a/federation/sds_federation/services/operational.py b/federation/sds_federation/services/operational.py new file mode 100644 index 000000000..30f6b6b0e --- /dev/null +++ b/federation/sds_federation/services/operational.py @@ -0,0 +1,138 @@ +"""Federation sync service operational checks for /health.""" + +from __future__ import annotations + +import asyncio +import os +from dataclasses import dataclass +from typing import Any + +import httpx +import redis.asyncio as aioredis +from opensearchpy import OpenSearch + +from sds_federation.models import FederationConfig + + +@dataclass(frozen=True, slots=True) +class CheckResult: + ok: bool + detail: str + + def as_dict(self) -> dict[str, str | bool]: + return {"ok": self.ok, "detail": self.detail} + + +def _skip_gateway_probe() -> bool: + return os.environ.get("FEDERATION_HEALTH_SKIP_GATEWAY_PROBE", "").lower() in ( + "1", + "true", + "yes", + ) + + +def check_config(config: FederationConfig | None) -> CheckResult: + if config is None: + return CheckResult(False, "federation config not loaded") + return CheckResult(True, f"site={config.site.name}") + + +def check_subscriber_task(task: asyncio.Task[None] | None) -> CheckResult: + if task is None: + return CheckResult(False, "redis subscriber not started") + if task.done(): + if task.cancelled(): + return CheckResult(False, "redis subscriber cancelled") + exc = task.exception() + if exc is not None: + return CheckResult(False, f"redis subscriber failed: {exc}") + return CheckResult(False, "redis subscriber stopped") + return CheckResult(True, "running") + + +async def check_redis(redis_url: str) -> CheckResult: + client = aioredis.from_url(redis_url) + try: + pong = await client.ping() + except Exception as exc: # noqa: BLE001 + return CheckResult(False, f"redis ping failed: {exc}") + finally: + await client.aclose() + if not pong: + return CheckResult(False, "redis ping returned false") + return CheckResult(True, "pong") + + +async def check_opensearch(client: OpenSearch | None) -> CheckResult: + if client is None: + return CheckResult(False, "opensearch client not configured") + + def _ping() -> bool: + return bool(client.ping()) + + try: + alive = await asyncio.to_thread(_ping) + except Exception as exc: # noqa: BLE001 + return CheckResult(False, f"opensearch ping failed: {exc}") + if not alive: + return CheckResult(False, "opensearch ping returned false") + return CheckResult(True, "pong") + + +async def check_gateway_export( + http: httpx.AsyncClient | None, + gateway_api_base: str, +) -> CheckResult: + if _skip_gateway_probe(): + return CheckResult(True, "gateway probe skipped") + if http is None: + return CheckResult(False, "gateway http client not configured") + url = f"{gateway_api_base.rstrip('/')}/federation/export/datasets/" + try: + resp = await http.get(url, timeout=2.0) + except httpx.HTTPError as exc: + return CheckResult(False, f"gateway export request failed: {exc}") + if resp.status_code == 200: + return CheckResult(True, "federation export reachable") + return CheckResult(False, f"gateway export returned HTTP {resp.status_code}") + + +async def evaluate_operational( + *, + config: FederationConfig | None, + http: httpx.AsyncClient | None, + opensearch: OpenSearch | None, + subscriber_task: asyncio.Task[None] | None, + redis_url: str | None = None, +) -> tuple[bool, dict[str, Any]]: + """Return (operational, body) for the health endpoint.""" + resolved_redis = redis_url or os.environ.get( + "REDIS_URL", + "redis://redis:6379/0", + ) + gateway_result: CheckResult + if config is None: + gateway_result = CheckResult(False, "federation config not loaded") + else: + gateway_result = await check_gateway_export( + http, + str(config.gateway_api_base), + ) + + checks: dict[str, dict[str, str | bool]] = { + "config": check_config(config).as_dict(), + "redis_subscriber": check_subscriber_task(subscriber_task).as_dict(), + "redis": (await check_redis(resolved_redis)).as_dict(), + "opensearch": (await check_opensearch(opensearch)).as_dict(), + "gateway_export": gateway_result.as_dict(), + } + + failed = [name for name, result in checks.items() if not result["ok"]] + operational = not failed + body: dict[str, Any] = { + "status": "ok" if operational else "unavailable", + "checks": checks, + } + if failed: + body["reason"] = f"failed checks: {', '.join(failed)}" + return operational, body diff --git a/federation/tests/test_operational.py b/federation/tests/test_operational.py new file mode 100644 index 000000000..58ecf9853 --- /dev/null +++ b/federation/tests/test_operational.py @@ -0,0 +1,151 @@ +"""Unit tests for federation sync operational health checks.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock +from unittest.mock import MagicMock +from unittest.mock import patch + +import httpx +import pytest +from sds_federation.models import FederationConfig +from sds_federation.models import SiteInfo +from sds_federation.services.operational import CheckResult +from sds_federation.services.operational import check_config +from sds_federation.services.operational import check_gateway_export +from sds_federation.services.operational import check_subscriber_task +from sds_federation.services.operational import evaluate_operational + + +def _sample_config() -> FederationConfig: + return FederationConfig( + site=SiteInfo(name="testsite", fqdn="test.example", display_name="Test"), + gateway_api_base="http://gateway:8000/api/v1", + sync_service_url="http://test.example/sync", + ) + + +def test_check_config_requires_loaded_config() -> None: + assert check_config(None).ok is False + good = check_config(_sample_config()) + assert good.ok is True + assert "testsite" in good.detail + + +@pytest.mark.asyncio +async def test_check_subscriber_task_running() -> None: + async def _noop() -> None: + await asyncio.sleep(3600) + + task = asyncio.create_task(_noop()) + try: + assert check_subscriber_task(task).ok is True + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +def test_check_subscriber_task_missing() -> None: + assert check_subscriber_task(None).ok is False + + +@pytest.mark.asyncio +async def test_check_gateway_export_success() -> None: + transport = httpx.MockTransport( + lambda request: httpx.Response(200, json=[]), + ) + async with httpx.AsyncClient(transport=transport) as client: + result = await check_gateway_export( + client, + "http://gateway:8000/api/v1", + ) + assert result.ok is True + + +@pytest.mark.asyncio +async def test_check_gateway_export_non_200() -> None: + transport = httpx.MockTransport( + lambda request: httpx.Response(503, json={"detail": "unavailable"}), + ) + async with httpx.AsyncClient(transport=transport) as client: + result = await check_gateway_export( + client, + "http://gateway:8000/api/v1", + ) + assert result.ok is False + assert "503" in result.detail + + +@pytest.mark.asyncio +async def test_evaluate_operational_all_pass() -> None: + config = _sample_config() + + async def _noop() -> None: + await asyncio.sleep(3600) + + sub_task = asyncio.create_task(_noop()) + mock_os = MagicMock() + mock_os.ping.return_value = True + + transport = httpx.MockTransport( + lambda request: httpx.Response(200, json=[]), + ) + redis_ok = CheckResult(True, "pong") + try: + async with httpx.AsyncClient(transport=transport) as http: + with patch( + "sds_federation.services.operational.check_redis", + new_callable=AsyncMock, + return_value=redis_ok, + ): + operational, body = await evaluate_operational( + config=config, + http=http, + opensearch=mock_os, + subscriber_task=sub_task, + ) + finally: + sub_task.cancel() + with pytest.raises(asyncio.CancelledError): + await sub_task + + assert operational is True + assert body["status"] == "ok" + assert body["checks"]["config"]["ok"] is True + + +@pytest.mark.asyncio +async def test_evaluate_operational_fails_when_subscriber_stopped() -> None: + config = _sample_config() + mock_os = MagicMock() + mock_os.ping.return_value = True + + done_task = asyncio.create_task(asyncio.sleep(0)) + await done_task + + redis_ok = CheckResult(True, "pong") + gateway_ok = CheckResult(True, "ok") + with ( + patch( + "sds_federation.services.operational.check_redis", + new_callable=AsyncMock, + return_value=redis_ok, + ), + patch( + "sds_federation.services.operational.check_gateway_export", + new_callable=AsyncMock, + return_value=gateway_ok, + ), + ): + operational, body = await evaluate_operational( + config=config, + http=MagicMock(), + opensearch=mock_os, + subscriber_task=done_task, + ) + + assert operational is False + assert body["status"] == "unavailable" + assert body["checks"]["redis_subscriber"]["ok"] is False From d0608d22539172ebd2b088a6adce275e28a2fa7e Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 26 Jun 2026 15:58:52 -0400 Subject: [PATCH 08/11] cursor bot comments --- federation/compose.yaml | 10 +- federation/pyproject.toml | 11 +- federation/sds_federation/main.py | 8 +- .../sds_federation/services/bootstrap.py | 7 +- .../sds_federation/services/fed_index.py | 13 +- .../sds_federation/services/local_events.py | 26 +- federation/tests/test_integration_mesh.py | 8 + federation/tests/test_integration_pipeline.py | 14 + federation/tests/test_redis_event_pipeline.py | 30 +- federation/tests/test_regression_indexer.py | 9 +- federation/uv.lock | 286 +++++++++++------- 11 files changed, 291 insertions(+), 131 deletions(-) diff --git a/federation/compose.yaml b/federation/compose.yaml index 73e6f95c3..e4b23d790 100644 --- a/federation/compose.yaml +++ b/federation/compose.yaml @@ -18,7 +18,15 @@ services: ports: - "8001:8000" healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + test: + [ + "CMD", + "uv", + "run", + "python", + "-c", + "import sys, httpx; r=httpx.get('http://127.0.0.1:8000/health', timeout=4); sys.exit(0 if r.status_code==200 else 1)", + ] interval: 30s timeout: 5s retries: 3 diff --git a/federation/pyproject.toml b/federation/pyproject.toml index c001e57da..fdd636bab 100644 --- a/federation/pyproject.toml +++ b/federation/pyproject.toml @@ -1,15 +1,16 @@ [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["uv_build>=0.11.24,<0.12"] +build-backend = "uv_build" -[tool.hatch.build.targets.wheel] -packages = ["sds_federation"] +[tool.uv.build-backend] +module-name = "sds_federation" +module-root = "" [project] name = "sds-federation" version = "0.1.0" description = "SDS Federation" - requires-python = ">=3.13,<3.14" + requires-python = ">=3.14,<3.15" dependencies = [ "fastapi>=0.115.6", "httpx>=0.28.1", diff --git a/federation/sds_federation/main.py b/federation/sds_federation/main.py index 6ab83d3af..40c05db2f 100644 --- a/federation/sds_federation/main.py +++ b/federation/sds_federation/main.py @@ -60,7 +60,13 @@ async def lifespan(app: FastAPI): stop = asyncio.Event() redis_url = os.environ.get("REDIS_URL", "redis://redis:6379/0") sub_task = asyncio.create_task( - run_federation_subscriber(redis_url, http, config, stop), + run_federation_subscriber( + redis_url, + http, + config, + app.state.fed_indexer, + stop, + ), ) app.state.subscriber_task = sub_task diff --git a/federation/sds_federation/services/bootstrap.py b/federation/sds_federation/services/bootstrap.py index 0ad8c6773..49e182b7f 100644 --- a/federation/sds_federation/services/bootstrap.py +++ b/federation/sds_federation/services/bootstrap.py @@ -67,7 +67,12 @@ async def fetch_peer_export_list( ) -> list[FederatedDatasetDoc | FederatedCaptureDoc]: url = _export_list_url(peer, asset_type) api_key = _resolve_gateway_api_key(peer) - data = await _get_json(http, url, api_key=api_key) + data = await _get_json( + http, + url, + api_key=api_key, + verify=peer.ca_cert_path or True, + ) if not isinstance(data, list): msg = f"expected list from {url}, got {type(data).__name__}" raise TypeError(msg) diff --git a/federation/sds_federation/services/fed_index.py b/federation/sds_federation/services/fed_index.py index 2dbea420e..0722782ee 100644 --- a/federation/sds_federation/services/fed_index.py +++ b/federation/sds_federation/services/fed_index.py @@ -52,15 +52,18 @@ def apply_asset_event( _id = doc_id(site_name, asset.uuid) if event_type == FederationEventType.DELETED: + tombstone = { + "is_federated_deleted": True, + "federation_event_at": event_at.isoformat(), + } + upsert_body = asset.model_dump(mode="json") + upsert_body.update(tombstone) self._client.update( index=asset_type.index_name, id=_id, body={ - "doc": { - "is_federated_deleted": True, - "federation_event_at": event_at.isoformat(), - }, - "doc_as_upsert": True, + "doc": tombstone, + "upsert": upsert_body, }, refresh="wait_for", ) diff --git a/federation/sds_federation/services/local_events.py b/federation/sds_federation/services/local_events.py index 40a2ea705..737b59942 100644 --- a/federation/sds_federation/services/local_events.py +++ b/federation/sds_federation/services/local_events.py @@ -14,6 +14,7 @@ from sds_federation.schemas.webhooks import FederatedCaptureDoc from sds_federation.schemas.webhooks import FederatedDatasetDoc from sds_federation.schemas.webhooks import FederationEventType +from sds_federation.services.fed_index import FederatedAssetIndexer from sds_federation.services.peer_sync import push_asset_updated_to_peers CHANNEL = "federation:events" @@ -66,11 +67,11 @@ def _tombstone_doc( def parse_redis_event_payload( data: dict, -) -> tuple[AssetTypeEnum, str, UUID, datetime] | None: +) -> tuple[AssetTypeEnum, FederationEventType, UUID, datetime] | None: """Parse a gateway-style federation:events message. Returns None if invalid.""" try: asset_type = AssetTypeEnum(data.get("item_type")) - event_type = data["event_type"] + event_type = FederationEventType(data["event_type"]) uuid = UUID(data["uuid"]) timestamp = datetime.fromisoformat(data["timestamp"]) except (KeyError, TypeError, ValueError): @@ -81,22 +82,30 @@ def parse_redis_event_payload( async def handle_redis_asset_event( http: httpx.AsyncClient, config: FederationConfig, + indexer: FederatedAssetIndexer, *, asset_type: AssetTypeEnum, - event_type: str, + event_type: FederationEventType, uuid: UUID, timestamp: datetime, resolve_asset: AssetResolver | None = None, ) -> None: - fed_event = FederationEventType(event_type) - if fed_event == FederationEventType.DELETED: + if event_type == FederationEventType.DELETED: asset = _tombstone_doc(config, uuid, asset_type) else: fetch = resolve_asset or fetch_local_public_asset asset = await fetch(http, config, uuid, asset_type) + indexer.apply_asset_event( + event_type=event_type, + event_at=timestamp, + site_name=config.site.name, + asset=asset, + asset_type=asset_type, + ) + payload = AssetUpdatedWebhook( - event_type=fed_event, + event_type=event_type, timestamp=timestamp, site_name=config.site.name, asset=asset, @@ -108,6 +117,7 @@ async def handle_redis_asset_event( async def dispatch_federation_redis_payload( http: httpx.AsyncClient, config: FederationConfig, + indexer: FederatedAssetIndexer, data: dict, *, resolve_asset: AssetResolver | None = None, @@ -124,6 +134,7 @@ async def dispatch_federation_redis_payload( await handle_redis_asset_event( http, config, + indexer, asset_type=asset_type, event_type=event_type, uuid=uuid, @@ -137,6 +148,7 @@ async def run_federation_subscriber( redis_url: str, http: httpx.AsyncClient, config: FederationConfig, + indexer: FederatedAssetIndexer, stop, ) -> None: client = aioredis.from_url(redis_url) @@ -150,7 +162,7 @@ async def run_federation_subscriber( continue data = json.loads(message["data"]) - await dispatch_federation_redis_payload(http, config, data) + await dispatch_federation_redis_payload(http, config, indexer, data) finally: await pubsub.unsubscribe(CHANNEL) await client.aclose() diff --git a/federation/tests/test_integration_mesh.py b/federation/tests/test_integration_mesh.py index 881069e46..b8e21dc3a 100644 --- a/federation/tests/test_integration_mesh.py +++ b/federation/tests/test_integration_mesh.py @@ -31,13 +31,21 @@ async def test_mesh_dispatches_redis_event_to_peer_opensearch( ) -> None: mesh = two_site_mesh payload = simulated_dataset_redis_payload() + local = mesh.site("testsite") + indexer = FederatedAssetIndexer(local.opensearch) ok = await dispatch_federation_redis_payload( mesh.http, test_site_config, + indexer, payload, resolve_asset=stub_dataset_resolver, ) assert ok is True + assert len(local.opensearch.index_calls) == 1 + assert local.opensearch.index_calls[0]["id"] == doc_id( + "testsite", + TEST_DATASET_UUID, + ) peer = mesh.site("peer-one") assert len(peer.opensearch.index_calls) == 1 assert peer.opensearch.index_calls[0]["id"] == doc_id( diff --git a/federation/tests/test_integration_pipeline.py b/federation/tests/test_integration_pipeline.py index 689483083..817af5ae4 100644 --- a/federation/tests/test_integration_pipeline.py +++ b/federation/tests/test_integration_pipeline.py @@ -4,12 +4,14 @@ import httpx import pytest +from sds_federation.services.fed_index import FederatedAssetIndexer from sds_federation.services.fed_index import doc_id from sds_federation.services.local_events import dispatch_federation_redis_payload from sds_federation.testing.sample_data import TEST_DATASET_UUID from sds_federation.testing.sample_data import simulated_dataset_redis_payload from tests.conftest import PEER_SYNC_BASE +from tests.support.mock_opensearch import RecordingOpenSearch @pytest.mark.integration @@ -20,6 +22,8 @@ async def test_redis_simulation_end_to_end_indexes_on_peer( peer_webhook_stack, ) -> None: recording_opensearch, asgi_transport = peer_webhook_stack + local_opensearch = RecordingOpenSearch() + indexer = FederatedAssetIndexer(local_opensearch) payload = simulated_dataset_redis_payload() async with httpx.AsyncClient( @@ -28,11 +32,17 @@ async def test_redis_simulation_end_to_end_indexes_on_peer( dispatched = await dispatch_federation_redis_payload( http, test_site_config, + indexer, payload, resolve_asset=stub_dataset_resolver, ) assert dispatched is True + assert len(local_opensearch.index_calls) == 1 + assert local_opensearch.index_calls[0]["id"] == doc_id( + "testsite", + TEST_DATASET_UUID, + ) assert len(recording_opensearch.index_calls) == 1 assert recording_opensearch.index_calls[0]["id"] == doc_id( "testsite", @@ -57,6 +67,8 @@ async def test_redis_subscriber_path_parses_and_dispatches( from sds_federation.services.local_events import dispatch_federation_redis_payload recording_opensearch, asgi_transport = peer_webhook_stack + local_opensearch = RecordingOpenSearch() + indexer = FederatedAssetIndexer(local_opensearch) raw = json.dumps(simulated_dataset_redis_payload()) async with httpx.AsyncClient( @@ -65,9 +77,11 @@ async def test_redis_subscriber_path_parses_and_dispatches( ok = await dispatch_federation_redis_payload( http, test_site_config, + indexer, json.loads(raw), resolve_asset=stub_dataset_resolver, ) assert ok is True + assert len(local_opensearch.index_calls) == 1 assert len(recording_opensearch.index_calls) == 1 diff --git a/federation/tests/test_redis_event_pipeline.py b/federation/tests/test_redis_event_pipeline.py index 32bd3636e..15d6ff8be 100644 --- a/federation/tests/test_redis_event_pipeline.py +++ b/federation/tests/test_redis_event_pipeline.py @@ -6,6 +6,9 @@ import httpx import pytest +from sds_federation.schemas.webhooks import FederationEventType +from sds_federation.services.fed_index import FederatedAssetIndexer +from sds_federation.services.fed_index import doc_id from sds_federation.services.local_events import dispatch_federation_redis_payload from sds_federation.services.local_events import parse_redis_event_payload from sds_federation.services.peer_sync import peer_webhook_url @@ -19,7 +22,7 @@ async def test_parse_simulated_redis_payload() -> None: parsed = parse_redis_event_payload(data) assert parsed is not None asset_type, event_type, uuid, _ts = parsed - assert event_type == "updated" + assert event_type == FederationEventType.UPDATED assert uuid == TEST_DATASET_UUID assert asset_type.value == "dataset" @@ -28,6 +31,15 @@ async def test_parse_simulated_redis_payload() -> None: async def test_invalid_redis_payload_ignored() -> None: assert parse_redis_event_payload({"item_type": "file"}) is None assert parse_redis_event_payload({"item_type": "dataset"}) is None + assert ( + parse_redis_event_payload( + { + **simulated_dataset_redis_payload(), + "event_type": "not-a-real-event", + } + ) + is None + ) @pytest.mark.asyncio @@ -35,19 +47,27 @@ async def test_dispatch_resolves_uuid_and_posts_webhook_to_peer( test_site_config, stub_dataset_resolver, peer_webhook_recorder, + recording_opensearch, ) -> None: recorded, transport = peer_webhook_recorder + indexer = FederatedAssetIndexer(recording_opensearch) payload = simulated_dataset_redis_payload() async with httpx.AsyncClient(transport=transport) as http: dispatched = await dispatch_federation_redis_payload( http, test_site_config, + indexer, payload, resolve_asset=stub_dataset_resolver, ) assert dispatched is True + assert len(recording_opensearch.index_calls) == 1 + assert recording_opensearch.index_calls[0]["id"] == doc_id( + "testsite", + TEST_DATASET_UUID, + ) assert len(recorded) == 1 req = recorded[0] expected_url = peer_webhook_url( @@ -68,8 +88,10 @@ async def test_dispatch_deleted_skips_resolver_and_sends_tombstone( test_site_config, stub_dataset_resolver, peer_webhook_recorder, + recording_opensearch, ) -> None: recorded, transport = peer_webhook_recorder + indexer = FederatedAssetIndexer(recording_opensearch) payload = simulated_dataset_redis_payload(event_type="deleted") async def fail_if_called(*_args, **_kwargs): @@ -79,10 +101,13 @@ async def fail_if_called(*_args, **_kwargs): await dispatch_federation_redis_payload( http, test_site_config, + indexer, payload, resolve_asset=fail_if_called, ) + assert len(recording_opensearch.update_calls) == 1 + assert len(recorded) == 1 body = json.loads(recorded[0].content.decode()) assert body["event_type"] == "deleted" @@ -95,8 +120,10 @@ async def test_dispatch_unknown_uuid_resolver_raises( test_site_config, stub_dataset_resolver, peer_webhook_recorder, + recording_opensearch, ) -> None: _, transport = peer_webhook_recorder + indexer = FederatedAssetIndexer(recording_opensearch) payload = simulated_dataset_redis_payload( uuid="00000000-0000-0000-0000-000000000099", ) @@ -106,6 +133,7 @@ async def test_dispatch_unknown_uuid_resolver_raises( await dispatch_federation_redis_payload( http, test_site_config, + indexer, payload, resolve_asset=stub_dataset_resolver, ) diff --git a/federation/tests/test_regression_indexer.py b/federation/tests/test_regression_indexer.py index a2d7022d5..8a7b11428 100644 --- a/federation/tests/test_regression_indexer.py +++ b/federation/tests/test_regression_indexer.py @@ -67,8 +67,13 @@ def test_indexer_delete_uses_update_with_tombstone( assert len(recording_opensearch.update_calls) == 1 assert recording_opensearch.index_calls == [] - doc = recording_opensearch.update_calls[0]["body"]["doc"] - assert doc["is_federated_deleted"] is True + body = recording_opensearch.update_calls[0]["body"] + assert body["doc"]["is_federated_deleted"] is True + upsert = body["upsert"] + assert upsert["is_federated_deleted"] is True + assert upsert["uuid"] == str(TEST_DATASET_UUID) + assert upsert["site_name"] == "testsite" + assert upsert["name"] == "Simulated public dataset" @pytest.mark.regression diff --git a/federation/uv.lock b/federation/uv.lock index 3121ca92b..3f2e50b0c 100644 --- a/federation/uv.lock +++ b/federation/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.13.*" +requires-python = "==3.14.*" [[package]] name = "annotated-doc" @@ -47,22 +47,38 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] @@ -143,16 +159,16 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, - { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, - { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, - { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, - { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, - { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, - { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, ] [[package]] @@ -183,13 +199,20 @@ version = "0.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, ] [[package]] @@ -324,21 +347,36 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, ] [[package]] @@ -424,16 +462,24 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] @@ -564,15 +610,24 @@ version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] @@ -636,12 +691,18 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, ] [[package]] @@ -653,31 +714,31 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, ] [[package]] @@ -686,15 +747,24 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] From ce44ac9aa5a48d3899ac0405fd3addaf34348060 Mon Sep 17 00:00:00 2001 From: klpoland Date: Wed, 1 Jul 2026 08:40:48 -0400 Subject: [PATCH 09/11] production vs local compose, add opensearch network --- .../{compose.yaml => compose.local.yaml} | 14 +++--- federation/compose.production.yaml | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) rename federation/{compose.yaml => compose.local.yaml} (86%) create mode 100644 federation/compose.production.yaml diff --git a/federation/compose.yaml b/federation/compose.local.yaml similarity index 86% rename from federation/compose.yaml rename to federation/compose.local.yaml index e4b23d790..9253d13db 100644 --- a/federation/compose.yaml +++ b/federation/compose.local.yaml @@ -1,3 +1,11 @@ +networks: + sds-network-local: + external: true + name: sds-network-local + sds-network-local-opensearch: + driver: bridge + name: sds-network-local-opensearch + services: sds-federation-local-sync: build: @@ -32,8 +40,4 @@ services: retries: 3 networks: - sds-network-local - -networks: - sds-network-local: - external: true - name: sds-network-local \ No newline at end of file + - sds-network-local-opensearch diff --git a/federation/compose.production.yaml b/federation/compose.production.yaml new file mode 100644 index 000000000..e35b9e3a6 --- /dev/null +++ b/federation/compose.production.yaml @@ -0,0 +1,43 @@ +networks: + sds-network-prod: + external: true + name: sds-network-prod + sds-network-prod-opensearch: + driver: bridge + name: sds-network-prod-opensearch + +services: + sds-federation-prod-sync: + build: + context: . + dockerfile: Dockerfile + image: sds-federation-prod-sync + container_name: sds-federation-prod-sync + env_file: + - .envs/production/sync.env + environment: + FEDERATION_CONFIG_PATH: /etc/sds/federation.toml + GATEWAY_INTERNAL_BASE_URL: http://sds-gateway-prod-app:8000/api/v1 + REDIS_URL: redis://sds-gateway-prod-redis:6379/0 + OPENSEARCH_HOST: sds-gateway-prod-opensearch + volumes: + - ./federation.toml:/etc/sds/federation.toml:ro + - ./certs:/etc/sds/certs:ro + ports: + - "8001:8000" + healthcheck: + test: + [ + "CMD", + "uv", + "run", + "python", + "-c", + "import sys, httpx; r=httpx.get('http://127.0.0.1:8000/health', timeout=4); sys.exit(0 if r.status_code==200 else 1)", + ] + interval: 30s + timeout: 5s + retries: 3 + networks: + - sds-network-prod + - sds-network-prod-opensearch From 3a7c39979a93097f301d06b91b308aa1994117a4 Mon Sep 17 00:00:00 2001 From: klpoland Date: Wed, 1 Jul 2026 09:07:13 -0400 Subject: [PATCH 10/11] set federation toml as example file for new sites to cp --- federation/.gitignore | 4 +++- federation/federation.example.toml | 14 ++++++++++++++ federation/federation.toml | 12 ------------ 3 files changed, 17 insertions(+), 13 deletions(-) create mode 100644 federation/federation.example.toml delete mode 100644 federation/federation.toml diff --git a/federation/.gitignore b/federation/.gitignore index 47e8b4e6c..8c7ad6af1 100644 --- a/federation/.gitignore +++ b/federation/.gitignore @@ -2,5 +2,7 @@ __pycache__/ +federation.toml + .envs/* -!/.envs/example \ No newline at end of file +!/.envs/example diff --git a/federation/federation.example.toml b/federation/federation.example.toml new file mode 100644 index 000000000..dab5fd981 --- /dev/null +++ b/federation/federation.example.toml @@ -0,0 +1,14 @@ +[site] +name = "your-site" +fqdn = "your-site.domain" +display_name = "Your Site" + +[[peers]] +# Notre Dame SDS site is the original site deployment +# and is included as a peer by default. +name = "crc" +fqdn = "sds.crc.nd.edu" +display_name = "Notre Dame CRC" +gateway_api_base = "https://sds.crc.nd.edu/api/v1" +sync_service_url = "https://sds.crc.nd.edu/sync/" +ca_cert_path = "/etc/sds/certs/crc-ca.pem" diff --git a/federation/federation.toml b/federation/federation.toml deleted file mode 100644 index eb2693e9f..000000000 --- a/federation/federation.toml +++ /dev/null @@ -1,12 +0,0 @@ -[site] -name = "crc" -fqdn = "sds.crc.nd.edu" -display_name = "Notre Dame CRC" - -[[peers]] -name = "haystack" -fqdn = "merrimack.haystack.mit.edu" -display_name = "MIT Haystack" -gateway_api_base = "https://merrimack.haystack.mit.edu/api/v1" -sync_service_url = "https://merrimack.haystack.mit.edu/sync/" -ca_cert_path = "/etc/sds/certs/haystack-ca.pem" \ No newline at end of file From 5f40bf16e8bb201f4ed877d8e9fac047129e2397 Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 2 Jul 2026 12:42:37 -0400 Subject: [PATCH 11/11] follow rfc 12: site-prefixed redis channels --- federation/.envs/example/sync.env | 4 +- federation/justfile | 34 +++++++- federation/scripts/env-selection.sh | 83 +++++++++++++++++++ federation/scripts/simulate_redis_event.py | 12 ++- .../sds_federation/services/local_events.py | 13 ++- .../sds_federation/services/redis_channel.py | 25 ++++++ .../tests/test_regression_redis_channel.py | 32 +++++++ 7 files changed, 193 insertions(+), 10 deletions(-) create mode 100755 federation/scripts/env-selection.sh create mode 100644 federation/sds_federation/services/redis_channel.py create mode 100644 federation/tests/test_regression_redis_channel.py diff --git a/federation/.envs/example/sync.env b/federation/.envs/example/sync.env index 73afb232b..5120953a1 100644 --- a/federation/.envs/example/sync.env +++ b/federation/.envs/example/sync.env @@ -1 +1,3 @@ -FEDERATION_GATEWAY_API_KEY= \ No newline at end of file +# Optional full channel override (default: federation:events:{site.name} from federation.toml) +# FEDERATION_EVENTS_CHANNEL= +FEDERATION_GATEWAY_API_KEY= diff --git a/federation/justfile b/federation/justfile index d300e8b41..7c8ef4bf8 100644 --- a/federation/justfile +++ b/federation/justfile @@ -1,13 +1,43 @@ set shell := ["bash", "-eu", "-o", "pipefail", "-c"] -compose_file := "compose.yaml" -docker_compose := "COMPOSE_FILE=" + compose_file + " docker compose" +# TIP: Production on this machine? Add hostname to ../gateway/scripts/prod-hostnames.env +# or set SDS_ENV=production|local + +env_selection_script := "./scripts/env-selection.sh" + +# variables | run `just env` to see current values + +compose_file := shell(env_selection_script + ' $1', "compose_file") +env := shell(env_selection_script + ' $1', "env") +env_file := shell(env_selection_script + ' $1', "env_file") +sync_container := shell(env_selection_script + ' $1', "sync_container") +docker_compose := "COMPOSE_FILE=" + compose_file + " docker compose --env-file " + env_file alias run := up default: @just --list +# prints currently selected environment (override with SDS_ENV=local|production) +[group('utilities')] +env: + #!/usr/bin/env bash + echo -e "\nSelected env:\n" + echo -e "\tEnvironment: \e[34m '{{ env }}'\e[0m" + echo -e "\tEnvironment file: \e[34m '{{ env_file }}'\e[0m" + echo -e "\tCompose file: \e[34m '{{ compose_file }}'\e[0m" + echo -e "\tSync container: \e[34m '{{ sync_container }}'\e[0m" + echo -e "\tDocker compose command: \e[34m '{{ docker_compose }}'\e[0m" + + if ! [ -f "{{ compose_file }}" ]; then + echo -e "\n\e[31mError:\e[0m Compose file '{{ compose_file }}' does not exist." + exit 1 + fi + if ! [ -f "{{ env_file }}" ]; then + echo -e "\n\e[33mWarning:\e[0m Env file '{{ env_file }}' does not exist." + echo -e "Copy from .envs/example/sync.env if needed." + fi + # install runtime + dev deps (pytest) [group('setup')] sync: diff --git a/federation/scripts/env-selection.sh b/federation/scripts/env-selection.sh new file mode 100755 index 000000000..3cb89bc2a --- /dev/null +++ b/federation/scripts/env-selection.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +FEDERATION_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GATEWAY_ENV_SCRIPT="${FEDERATION_ROOT}/../gateway/scripts/env-selection.sh" + +function resolve_env_type() { + local env_type + + if [[ -n "${SDS_ENV:-}" ]]; then + case "${SDS_ENV}" in + ci | local | production) env_type="${SDS_ENV}" ;; + *) + printf '\033[33mUnknown SDS_ENV="%s": must be ci, local, or production\033[0m\n' "${SDS_ENV}" >&2 + exit 1 + ;; + esac + elif [[ -f "${GATEWAY_ENV_SCRIPT}" ]]; then + env_type=$("${GATEWAY_ENV_SCRIPT}" env) + else + env_type='local' + fi + + case "${env_type}" in + ci) printf 'local\n' ;; + *) printf '%s\n' "${env_type}" ;; + esac +} + +function get_target_value() { + local target=$1 + local env_type=$2 + local value + + case "${target}" in + env) + value="${env_type}" + ;; + compose_file) + case "${env_type}" in + production) value='compose.production.yaml' ;; + local) value='compose.local.yaml' ;; + esac + ;; + sync_container) + case "${env_type}" in + production) value='sds-federation-prod-sync' ;; + local) value='sds-federation-local-sync' ;; + esac + ;; + env_file) + case "${env_type}" in + production) value='.envs/production/sync.env' ;; + local) value='.envs/local/sync.env' ;; + esac + ;; + *) + printf 'unsupported target: %s (use env, compose_file, sync_container, or env_file)\n' "${target}" >&2 + exit 1 + ;; + esac + + if [[ "${target}" == "compose_file" && ! -f "${FEDERATION_ROOT}/${value}" ]]; then + printf '\033[31mERROR: selected compose file "%s" does not exist\033[0m\n' "${value}" >&2 + exit 1 + fi + + printf '%s\n' "${value}" +} + +function main() { + if [[ $# -ne 1 ]]; then + printf 'usage: %s \n' "${0}" >&2 + exit 1 + fi + + local env_type + env_type=$(resolve_env_type) + get_target_value "${1}" "${env_type}" +} + +main "$@" diff --git a/federation/scripts/simulate_redis_event.py b/federation/scripts/simulate_redis_event.py index 0e681fa61..dca73a698 100644 --- a/federation/scripts/simulate_redis_event.py +++ b/federation/scripts/simulate_redis_event.py @@ -17,7 +17,8 @@ from uuid import UUID import redis -from sds_federation.services.local_events import CHANNEL +from sds_federation.models import load_federation_config +from sds_federation.services.redis_channel import resolve_federation_events_channel from sds_federation.testing.sample_data import TEST_DATASET_UUID from sds_federation.testing.sample_data import simulated_dataset_redis_payload @@ -39,13 +40,18 @@ def main() -> int: args = parser.parse_args() redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0") + config = load_federation_config() + channel = resolve_federation_events_channel( + site_name=config.site.name, + env_override=os.environ.get("FEDERATION_EVENTS_CHANNEL"), + ) payload = simulated_dataset_redis_payload( uuid=UUID(args.uuid), event_type=args.event_type, ) client = redis.from_url(redis_url) - receivers = client.publish(CHANNEL, json.dumps(payload)) - print(f"Published to {CHANNEL!r} on {redis_url} ({receivers} subscribers)") + receivers = client.publish(channel, json.dumps(payload)) + print(f"Published to {channel!r} on {redis_url} ({receivers} subscribers)") print(json.dumps(payload, indent=2)) return 0 diff --git a/federation/sds_federation/services/local_events.py b/federation/sds_federation/services/local_events.py index 737b59942..701d6cd66 100644 --- a/federation/sds_federation/services/local_events.py +++ b/federation/sds_federation/services/local_events.py @@ -16,8 +16,7 @@ from sds_federation.schemas.webhooks import FederationEventType from sds_federation.services.fed_index import FederatedAssetIndexer from sds_federation.services.peer_sync import push_asset_updated_to_peers - -CHANNEL = "federation:events" +from sds_federation.services.redis_channel import resolve_federation_events_channel type AssetResolver = Callable[ [httpx.AsyncClient, FederationConfig, UUID, AssetTypeEnum], @@ -150,10 +149,16 @@ async def run_federation_subscriber( config: FederationConfig, indexer: FederatedAssetIndexer, stop, + *, + channel: str | None = None, ) -> None: + resolved_channel = channel or resolve_federation_events_channel( + site_name=config.site.name, + env_override=os.environ.get("FEDERATION_EVENTS_CHANNEL"), + ) client = aioredis.from_url(redis_url) pubsub = client.pubsub() - await pubsub.subscribe(CHANNEL) + await pubsub.subscribe(resolved_channel) try: async for message in pubsub.listen(): if stop.is_set(): @@ -164,7 +169,7 @@ async def run_federation_subscriber( await dispatch_federation_redis_payload(http, config, indexer, data) finally: - await pubsub.unsubscribe(CHANNEL) + await pubsub.unsubscribe(resolved_channel) await client.aclose() diff --git a/federation/sds_federation/services/redis_channel.py b/federation/sds_federation/services/redis_channel.py new file mode 100644 index 000000000..d0ddbd9ac --- /dev/null +++ b/federation/sds_federation/services/redis_channel.py @@ -0,0 +1,25 @@ +"""Redis pub/sub channel naming for federation local change events.""" + +from __future__ import annotations + +FEDERATION_EVENTS_CHANNEL_PREFIX = "federation:events" + + +def federation_events_channel(site_name: str) -> str: + """Site-scoped channel.""" + name = site_name.strip() + if not name: + msg = "site_name is required to build a federation events channel" + raise ValueError(msg) + return f"{FEDERATION_EVENTS_CHANNEL_PREFIX}:{name}" + + +def resolve_federation_events_channel( + *, + site_name: str, + env_override: str | None = None, +) -> str: + """Channel for subscribe/publish; env override wins when set.""" + if env_override is not None and env_override.strip(): + return env_override.strip() + return federation_events_channel(site_name) diff --git a/federation/tests/test_regression_redis_channel.py b/federation/tests/test_regression_redis_channel.py new file mode 100644 index 000000000..a2f0c5c28 --- /dev/null +++ b/federation/tests/test_regression_redis_channel.py @@ -0,0 +1,32 @@ +"""Regression tests for site-prefixed federation Redis channels.""" + +import pytest +from sds_federation.services.redis_channel import federation_events_channel +from sds_federation.services.redis_channel import resolve_federation_events_channel + + +@pytest.mark.regression +def test_federation_events_channel_uses_site_name() -> None: + assert federation_events_channel("crc") == "federation:events:crc" + + +def test_federation_events_channel_rejects_blank_site() -> None: + with pytest.raises(ValueError, match="site_name"): + federation_events_channel(" ") + + +def test_resolve_prefers_env_override() -> None: + assert ( + resolve_federation_events_channel( + site_name="crc", + env_override="custom:channel", + ) + == "custom:channel" + ) + + +def test_resolve_derives_from_site_when_no_override() -> None: + assert ( + resolve_federation_events_channel(site_name="haystack", env_override=None) + == "federation:events:haystack" + )