diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e4b71e55..25bd1a99 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] @@ -110,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/.envs/example/sync.env b/federation/.envs/example/sync.env new file mode 100644 index 00000000..5120953a --- /dev/null +++ b/federation/.envs/example/sync.env @@ -0,0 +1,3 @@ +# Optional full channel override (default: federation:events:{site.name} from federation.toml) +# FEDERATION_EVENTS_CHANNEL= +FEDERATION_GATEWAY_API_KEY= diff --git a/federation/.gitignore b/federation/.gitignore new file mode 100644 index 00000000..8c7ad6af --- /dev/null +++ b/federation/.gitignore @@ -0,0 +1,8 @@ +# Dev PKI material — generate with scripts/generate-dev-certs.sh + +__pycache__/ + +federation.toml + +.envs/* +!/.envs/example diff --git a/federation/Dockerfile b/federation/Dockerfile new file mode 100644 index 00000000..ae220634 --- /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 00000000..812858d2 --- /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.local.yaml b/federation/compose.local.yaml new file mode 100644 index 00000000..9253d13d --- /dev/null +++ b/federation/compose.local.yaml @@ -0,0 +1,43 @@ +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: + 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", + "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-local + - sds-network-local-opensearch diff --git a/federation/compose.production.yaml b/federation/compose.production.yaml new file mode 100644 index 00000000..e35b9e3a --- /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 diff --git a/federation/federation.example.toml b/federation/federation.example.toml new file mode 100644 index 00000000..dab5fd98 --- /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/justfile b/federation/justfile new file mode 100644 index 00000000..7c8ef4bf --- /dev/null +++ b/federation/justfile @@ -0,0 +1,101 @@ +set shell := ["bash", "-eu", "-o", "pipefail", "-c"] + +# 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: + 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 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')] +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 00000000..fdd636ba --- /dev/null +++ b/federation/pyproject.toml @@ -0,0 +1,167 @@ +[build-system] +requires = ["uv_build>=0.11.24,<0.12"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "sds_federation" +module-root = "" + +[project] + name = "sds-federation" + version = "0.1.0" + description = "SDS Federation" + requires-python = ">=3.14,<3.15" + 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", + "pyrefly>=0.42.1", + "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", + "pyrefly", + "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.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"] +markers = [ + "integration: tests that exercise FastAPI routes or multi-step pipeline", + "regression: contract and indexer behavior guards", +] diff --git a/federation/scripts/env-selection.sh b/federation/scripts/env-selection.sh new file mode 100755 index 00000000..3cb89bc2 --- /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/generate-dev-certs.sh b/federation/scripts/generate-dev-certs.sh new file mode 100755 index 00000000..b9067d95 --- /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/scripts/simulate_redis_event.py b/federation/scripts/simulate_redis_event.py new file mode 100644 index 00000000..dca73a69 --- /dev/null +++ b/federation/scripts/simulate_redis_event.py @@ -0,0 +1,60 @@ +#!/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.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 + + +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") + 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)") + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/federation/sds_federation/__init__.py b/federation/sds_federation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/federation/sds_federation/main.py b/federation/sds_federation/main.py new file mode 100644 index 00000000..40c05db2 --- /dev/null +++ b/federation/sds_federation/main.py @@ -0,0 +1,84 @@ +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.opensearch_client = os_client + 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, + app.state.fed_indexer, + stop, + ), + ) + app.state.subscriber_task = sub_task + + 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 00000000..8d4165ab --- /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/__init__.py b/federation/sds_federation/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/federation/sds_federation/routes/health.py b/federation/sds_federation/routes/health.py new file mode 100644 index 00000000..5fd4bc37 --- /dev/null +++ b/federation/sds_federation/routes/health.py @@ -0,0 +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(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/routes/webhooks.py b/federation/sds_federation/routes/webhooks.py new file mode 100644 index 00000000..c11207a2 --- /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/__init__.py b/federation/sds_federation/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/federation/sds_federation/schemas/webhooks.py b/federation/sds_federation/schemas/webhooks.py new file mode 100644 index 00000000..eec709b5 --- /dev/null +++ b/federation/sds_federation/schemas/webhooks.py @@ -0,0 +1,114 @@ +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 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) + + +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 + 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/__init__.py b/federation/sds_federation/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/federation/sds_federation/services/bootstrap.py b/federation/sds_federation/services/bootstrap.py new file mode 100644 index 00000000..49e182b7 --- /dev/null +++ b/federation/sds_federation/services/bootstrap.py @@ -0,0 +1,222 @@ +"""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.schemas.webhooks import asset_doc_class +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, + 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) + doc_class = asset_doc_class(asset_type) + 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(config, http, 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 00000000..0722782e --- /dev/null +++ b/federation/sds_federation/services/fed_index.py @@ -0,0 +1,81 @@ +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: + 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": tombstone, + "upsert": upsert_body, + }, + 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 00000000..701d6cd6 --- /dev/null +++ b/federation/sds_federation/services/local_events.py @@ -0,0 +1,181 @@ +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.fed_index import FederatedAssetIndexer +from sds_federation.services.peer_sync import push_asset_updated_to_peers +from sds_federation.services.redis_channel import resolve_federation_events_channel + +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, 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 = FederationEventType(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, + indexer: FederatedAssetIndexer, + *, + asset_type: AssetTypeEnum, + event_type: FederationEventType, + uuid: UUID, + timestamp: datetime, + resolve_asset: AssetResolver | None = None, +) -> None: + 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=event_type, + 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, + indexer: FederatedAssetIndexer, + 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, + indexer, + 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, + 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(resolved_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, indexer, data) + finally: + await pubsub.unsubscribe(resolved_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/operational.py b/federation/sds_federation/services/operational.py new file mode 100644 index 00000000..30f6b6b0 --- /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/sds_federation/services/peer_registry.py b/federation/sds_federation/services/peer_registry.py new file mode 100644 index 00000000..28ace072 --- /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 00000000..0a4c4b9b --- /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) diff --git a/federation/sds_federation/services/redis_channel.py b/federation/sds_federation/services/redis_channel.py new file mode 100644 index 00000000..d0ddbd9a --- /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/sds_federation/testing/__init__.py b/federation/sds_federation/testing/__init__.py new file mode 100644 index 00000000..808878a7 --- /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 00000000..abf33b4c --- /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 00000000..e69de29b diff --git a/federation/tests/conftest.py b/federation/tests/conftest.py new file mode 100644 index 00000000..65e14159 --- /dev/null +++ b/federation/tests/conftest.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import httpx +import pytest +from fastapi import FastAPI +from sds_federation.models import FederationConfig +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.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" +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.""" + 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: + 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 +def recording_opensearch() -> RecordingOpenSearch: + return RecordingOpenSearch() + + +@pytest.fixture +def test_site_config() -> FederationConfig: + return testsite_config() + + +@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 +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.""" + 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 00000000..e69de29b diff --git a/federation/tests/support/federation_mesh.py b/federation/tests/support/federation_mesh.py new file mode 100644 index 00000000..d46cad08 --- /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 00000000..3bafb736 --- /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_opensearch.py b/federation/tests/support/mock_opensearch.py new file mode 100644 index 00000000..12453bc2 --- /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/support/mock_peer_registry.py b/federation/tests/support/mock_peer_registry.py new file mode 100644 index 00000000..e036188b --- /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 new file mode 100644 index 00000000..ba1188ab --- /dev/null +++ b/federation/tests/test_integration_bootstrap.py @@ -0,0 +1,108 @@ +"""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_sync_url = f"{PEER_SYNC_BASE}/sync" + + 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_url, + ), + ], + ) + peer = caller_config.peers[0] + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url=peer_sync_url, + ) 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_mesh.py b/federation/tests/test_integration_mesh.py new file mode 100644 index 00000000..b8e21dc3 --- /dev/null +++ b/federation/tests/test_integration_mesh.py @@ -0,0 +1,113 @@ +"""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() + 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( + "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_pipeline.py b/federation/tests/test_integration_pipeline.py new file mode 100644 index 00000000..817af5ae --- /dev/null +++ b/federation/tests/test_integration_pipeline.py @@ -0,0 +1,87 @@ +"""Integration: simulated Redis dispatch → outbound POST → peer webhook → OpenSearch.""" + +from __future__ import annotations + +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 +@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 + local_opensearch = RecordingOpenSearch() + indexer = FederatedAssetIndexer(local_opensearch) + 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, + 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", + 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 + local_opensearch = RecordingOpenSearch() + indexer = FederatedAssetIndexer(local_opensearch) + 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, + 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_integration_webhooks.py b/federation/tests/test_integration_webhooks.py new file mode 100644 index 00000000..ed9b4101 --- /dev/null +++ b/federation/tests/test_integration_webhooks.py @@ -0,0 +1,177 @@ +"""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 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 SYNC_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"{SYNC_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"] == AssetTypeEnum.DATASET.index_name + ) + 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"{SYNC_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"{SYNC_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"{SYNC_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"{SYNC_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"{SYNC_API_PREFIX}/webhook/site-hello", json=body) + + assert response.status_code == 422 diff --git a/federation/tests/test_operational.py b/federation/tests/test_operational.py new file mode 100644 index 00000000..58ecf985 --- /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 diff --git a/federation/tests/test_redis_event_pipeline.py b/federation/tests/test_redis_event_pipeline.py new file mode 100644 index 00000000..15d6ff8b --- /dev/null +++ b/federation/tests/test_redis_event_pipeline.py @@ -0,0 +1,139 @@ +"""Isolated sync tests: simulated Redis payload → stub asset → peer webhook.""" + +from __future__ import annotations + +import json + +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 +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 == FederationEventType.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 + assert ( + parse_redis_event_payload( + { + **simulated_dataset_redis_payload(), + "event_type": "not-a-real-event", + } + ) + 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, + 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( + 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, + 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): + 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, + 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" + 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, + recording_opensearch, +) -> None: + _, transport = peer_webhook_recorder + indexer = FederatedAssetIndexer(recording_opensearch) + 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, + indexer, + 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 00000000..8a7b1142 --- /dev/null +++ b/federation/tests/test_regression_indexer.py @@ -0,0 +1,118 @@ +"""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 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"] == 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() + 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 == [] + 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 +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_redis_channel.py b/federation/tests/test_regression_redis_channel.py new file mode 100644 index 00000000..a2f0c5c2 --- /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" + ) diff --git a/federation/tests/test_regression_schemas.py b/federation/tests/test_regression_schemas.py new file mode 100644 index 00000000..adcdec5e --- /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" diff --git a/federation/uv.lock b/federation/uv.lock new file mode 100644 index 00000000..3f2e50b0 --- /dev/null +++ b/federation/uv.lock @@ -0,0 +1,778 @@ +version = 1 +revision = 3 +requires-python = "==3.14.*" + +[[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/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "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/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]] +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/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]] +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/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]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyrefly" +version = "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" +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/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "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 = "pyrefly" }, + { 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 = "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" }, + { 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/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" }, +] + +[[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/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]] +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/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]] +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/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" }, +] + +[[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" }, +]