From 284e98e14b38f6276a555b545e7f278ff02fd8df Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Thu, 15 Oct 2026 16:13:43 +0000 Subject: [PATCH 1/4] Initial work on acknowledg forge for integrating software forges with acknowledg [] --- .github/workflows/acknowledg-deploy.yml | 7 +- .github/workflows/acknowledg-forge-deploy.yml | 140 +++ .github/workflows/acknowledg-forge.yml | 61 ++ .github/workflows/build-and-test-ci.yml | 101 ++ dev_tools/docker/acknowledg-forge/Dockerfile | 56 ++ dev_tools/docker/acknowledg-forge/prebuild.py | 70 ++ .../setup-scripts/install-software.sh | 26 + .../acknowledg-forge/setup-scripts/run-all.sh | 8 + .../pylibs/edgacknowledg/client/__init__.py | 114 ++- .../edgacknowledg/client/bench/__init__.py | 21 +- .../edgacknowledg/client/test/__init__.py | 21 +- .../pylibs/edgacknowledg/forge/__init__.py | 208 ++++ .../pylibs/edgacknowledg/forge/announcer.py | 204 ++++ .../pylibs/edgacknowledg/forge/config.py | 201 ++++ .../pylibs/edgacknowledg/forge/db/__init__.py | 20 + .../forge/db/migrations/__init__.py | 6 + .../edgacknowledg/forge/db/migrations/env.py | 40 + .../forge/db/migrations/script.py.mako | 28 + .../versions/0001_initial_schema.py | 111 ++ .../forge/db/migrations/versions/__init__.py | 5 + .../pylibs/edgacknowledg/forge/db/models.py | 173 ++++ .../pylibs/edgacknowledg/forge/db/store.py | 567 +++++++++++ dev_tools/pylibs/edgacknowledg/forge/http.py | 185 ++++ .../pylibs/edgacknowledg/forge/ingest.py | 126 +++ .../pylibs/edgacknowledg/forge/poller.py | 40 + .../pylibs/edgacknowledg/forge/processor.py | 660 ++++++++++++ .../pylibs/edgacknowledg/forge/publisher.py | 243 +++++ .../pylibs/edgacknowledg/forge/sandbox.py | 197 ++++ .../pylibs/edgacknowledg/forge/service.py | 178 ++++ .../pylibs/edgacknowledg/forge/webhooks.py | 86 ++ .../pylibs/edgacknowledg/forge/workspace.py | 523 ++++++++++ dev_tools/pylibs/edgdocker/__init__.py | 3 +- dev_tools/pylibs/edgtest/__init__.py | 31 +- dev_tools/services/acknowledg-forge/README.md | 474 +++++++++ .../acknowledg-forge/deploy-remote.sh | 162 +++ .../acknowledg-forge/github/.gitignore | 8 + .../acknowledg_forge_github/__init__.py | 291 ++++++ .../acknowledg_forge_github/__main__.py | 40 + .../services/acknowledg-forge/github/build.sh | 8 + .../acknowledg-forge/github/edgacknowledg | 1 + .../services/acknowledg-forge/github/edgtest | 1 + .../services/acknowledg-forge/github/edgutil | 1 + .../acknowledg-forge/github/pyproject.toml | 37 + .../acknowledg-forge/github/run-service.sh | 12 + .../acknowledg-forge/github/setup-venv.sh | 8 + .../services/acknowledg-forge/github/uv.lock | 946 ++++++++++++++++++ .../acknowledg-forge/gitlab/.gitignore | 8 + .../acknowledg_forge_gitlab/__init__.py | 320 ++++++ .../acknowledg_forge_gitlab/__main__.py | 44 + .../services/acknowledg-forge/gitlab/build.sh | 8 + .../acknowledg-forge/gitlab/edgacknowledg | 1 + .../services/acknowledg-forge/gitlab/edgtest | 1 + .../services/acknowledg-forge/gitlab/edgutil | 1 + .../acknowledg-forge/gitlab/pyproject.toml | 37 + .../acknowledg-forge/gitlab/run-service.sh | 12 + .../acknowledg-forge/gitlab/setup-venv.sh | 8 + .../services/acknowledg-forge/gitlab/uv.lock | 946 ++++++++++++++++++ .../acknowledg-forge/tests/.gitignore | 2 + .../acknowledg-forge/tests/conftest.py | 359 +++++++ .../services/acknowledg-forge/tests/fakes.py | 169 ++++ .../acknowledg-forge/tests/pytest.ini | 4 + .../acknowledg-forge/tests/run-tests.sh | 14 + .../tests/test_acknowledg_integration.py | 192 ++++ .../acknowledg-forge/tests/test_github.py | 233 +++++ .../acknowledg-forge/tests/test_gitlab.py | 224 +++++ .../acknowledg-forge/tests/test_lifecycle.py | 227 +++++ .../acknowledg-forge/tests/test_safety.py | 221 ++++ .../acknowledg-forge/tests/test_state.py | 297 ++++++ .../services/acknowledg-forge/typecheck.sh | 25 + dev_tools/services/acknowledg/README.md | 7 +- .../services/acknowledg/server/pyproject.toml | 2 + 71 files changed, 9749 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/acknowledg-forge-deploy.yml create mode 100644 .github/workflows/acknowledg-forge.yml create mode 100644 dev_tools/docker/acknowledg-forge/Dockerfile create mode 100644 dev_tools/docker/acknowledg-forge/prebuild.py create mode 100755 dev_tools/docker/acknowledg-forge/setup-scripts/install-software.sh create mode 100755 dev_tools/docker/acknowledg-forge/setup-scripts/run-all.sh create mode 100644 dev_tools/pylibs/edgacknowledg/forge/__init__.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/announcer.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/config.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/db/__init__.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/db/migrations/__init__.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/db/migrations/env.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/db/migrations/script.py.mako create mode 100644 dev_tools/pylibs/edgacknowledg/forge/db/migrations/versions/0001_initial_schema.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/db/migrations/versions/__init__.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/db/models.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/db/store.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/http.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/ingest.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/poller.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/processor.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/publisher.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/sandbox.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/service.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/webhooks.py create mode 100644 dev_tools/pylibs/edgacknowledg/forge/workspace.py create mode 100644 dev_tools/services/acknowledg-forge/README.md create mode 100644 dev_tools/services/acknowledg-forge/deploy-remote.sh create mode 100644 dev_tools/services/acknowledg-forge/github/.gitignore create mode 100644 dev_tools/services/acknowledg-forge/github/acknowledg_forge_github/__init__.py create mode 100644 dev_tools/services/acknowledg-forge/github/acknowledg_forge_github/__main__.py create mode 100755 dev_tools/services/acknowledg-forge/github/build.sh create mode 120000 dev_tools/services/acknowledg-forge/github/edgacknowledg create mode 120000 dev_tools/services/acknowledg-forge/github/edgtest create mode 120000 dev_tools/services/acknowledg-forge/github/edgutil create mode 100644 dev_tools/services/acknowledg-forge/github/pyproject.toml create mode 100755 dev_tools/services/acknowledg-forge/github/run-service.sh create mode 100755 dev_tools/services/acknowledg-forge/github/setup-venv.sh create mode 100644 dev_tools/services/acknowledg-forge/github/uv.lock create mode 100644 dev_tools/services/acknowledg-forge/gitlab/.gitignore create mode 100644 dev_tools/services/acknowledg-forge/gitlab/acknowledg_forge_gitlab/__init__.py create mode 100644 dev_tools/services/acknowledg-forge/gitlab/acknowledg_forge_gitlab/__main__.py create mode 100755 dev_tools/services/acknowledg-forge/gitlab/build.sh create mode 120000 dev_tools/services/acknowledg-forge/gitlab/edgacknowledg create mode 120000 dev_tools/services/acknowledg-forge/gitlab/edgtest create mode 120000 dev_tools/services/acknowledg-forge/gitlab/edgutil create mode 100644 dev_tools/services/acknowledg-forge/gitlab/pyproject.toml create mode 100755 dev_tools/services/acknowledg-forge/gitlab/run-service.sh create mode 100755 dev_tools/services/acknowledg-forge/gitlab/setup-venv.sh create mode 100644 dev_tools/services/acknowledg-forge/gitlab/uv.lock create mode 100644 dev_tools/services/acknowledg-forge/tests/.gitignore create mode 100644 dev_tools/services/acknowledg-forge/tests/conftest.py create mode 100644 dev_tools/services/acknowledg-forge/tests/fakes.py create mode 100644 dev_tools/services/acknowledg-forge/tests/pytest.ini create mode 100755 dev_tools/services/acknowledg-forge/tests/run-tests.sh create mode 100644 dev_tools/services/acknowledg-forge/tests/test_acknowledg_integration.py create mode 100644 dev_tools/services/acknowledg-forge/tests/test_github.py create mode 100644 dev_tools/services/acknowledg-forge/tests/test_gitlab.py create mode 100644 dev_tools/services/acknowledg-forge/tests/test_lifecycle.py create mode 100644 dev_tools/services/acknowledg-forge/tests/test_safety.py create mode 100644 dev_tools/services/acknowledg-forge/tests/test_state.py create mode 100755 dev_tools/services/acknowledg-forge/typecheck.sh diff --git a/.github/workflows/acknowledg-deploy.yml b/.github/workflows/acknowledg-deploy.yml index a1b6676b93..e13f938a9b 100644 --- a/.github/workflows/acknowledg-deploy.yml +++ b/.github/workflows/acknowledg-deploy.yml @@ -16,7 +16,8 @@ # Host, paths, and credentials come from secrets in the `acknowledg` # environment (see dev_tools/services/acknowledg/README.md). The SSH setup # writes them into a `Host acknowledg` alias so they never appear on a command -# line. +# line. The public API URL is the ACKNOWLEDG_API_HOST repository variable, +# shared with acknowledg-forge-deploy.yml. name: AcknowlEDG Deploy @@ -27,6 +28,8 @@ on: paths: - dev_tools/services/acknowledg/** - dev_tools/pylibs/edgacknowledg/** + # acknowledg-forge is a separate service. + - '!dev_tools/pylibs/edgacknowledg/forge/**' - .github/workflows/acknowledg-deploy.yml workflow_dispatch: @@ -61,7 +64,7 @@ jobs: - name: Build client working-directory: ${{ env.ACKNOWLEDG_DIR }}/client env: - VITE_EDG_ACKNOWLEDG_API_HOST: ${{ secrets.ACKNOWLEDG_API_HOST }} + VITE_EDG_ACKNOWLEDG_API_HOST: ${{ vars.ACKNOWLEDG_API_HOST }} run: | # The client opens `${VITE_EDG_ACKNOWLEDG_API_HOST}/review`; without # a scheme the browser resolves that relative to the page instead. diff --git a/.github/workflows/acknowledg-forge-deploy.yml b/.github/workflows/acknowledg-forge-deploy.yml new file mode 100644 index 0000000000..0b5a061c76 --- /dev/null +++ b/.github/workflows/acknowledg-forge-deploy.yml @@ -0,0 +1,140 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# Publish the acknowledg-forge container image and deploy it: +# +# test the acknowledg-forge typecheck and tests (acknowledg-forge.yml) +# publish build the image (build-edg-env acknowledg-forge) and push it to +# ghcr.io//acknowledg-forge:latest (deployments use the +# pushed digest) +# deploy over SSH, run the host's deploy helper (an administrator- +# installed copy of +# dev_tools/services/acknowledg-forge/deploy-remote.sh, the only +# thing the deploy user may run as root) to point the service at +# the pushed digest, set its AcknowlEDG API and UI URLs, and +# restart it (only if any of those changed) +# +# The image is public and holds no credentials or configuration: the publish +# job has no access to the deployment's secrets. Everything else about the +# service (its unit, tokens, API key) is configured on the host. +# +# Host, user, and SSH credentials come from secrets in the +# `acknowledg-forge` environment; the public AcknowlEDG URLs come from +# repository variables shared with acknowledg-deploy.yml (see +# dev_tools/services/acknowledg-forge/README.md). + +name: acknowledg-forge Deploy + +on: + push: + branches: + - main + paths: + # The image freezes all of dev_tools/bin and dev_tools/pylibs. + - dev_tools/bin/** + - dev_tools/pylibs/** + - dev_tools/services/acknowledg-forge/** + - dev_tools/docker/acknowledg-forge/** + - .github/workflows/acknowledg-forge.yml + - .github/workflows/acknowledg-forge-deploy.yml + workflow_dispatch: + +concurrency: + group: acknowledg-forge-deploy + cancel-in-progress: false + +permissions: + contents: read + +jobs: + test: + name: Test + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/acknowledg-forge.yml + + publish: + name: Publish image + needs: test + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.push.outputs.image }} + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up uv + uses: astral-sh/setup-uv@v10.3.0 + + - name: Build image + env: + PYTHONPATH: ${{ github.workspace }}/dev_tools/pylibs + run: dev_tools/bin/build-edg-env --quiet acknowledg-forge + + - name: Push image + id: push + env: + OWNER: ${{ github.repository_owner }} + REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + image="ghcr.io/${OWNER,,}/acknowledg-forge" + printf '%s' "${REGISTRY_TOKEN}" \ + | docker login ghcr.io --username "${GITHUB_ACTOR}" \ + --password-stdin + docker tag docker.io/edgcpp/acknowledg-forge:latest "${image}:latest" + docker push --quiet "${image}:latest" + docker logout ghcr.io + digest="$(docker inspect \ + --format '{{range .RepoDigests}}{{println .}}{{end}}' \ + "${image}:latest" | grep "^${image}@sha256:" | head -n 1)" + echo "Pushed ${digest}" + echo "image=${digest}" >> "${GITHUB_OUTPUT}" + + deploy: + name: Deploy + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: acknowledg-forge + steps: + - name: Configure SSH + env: + SSH_HOST: ${{ secrets.ACKNOWLEDG_FORGE_SSH_HOST }} + SSH_USER: ${{ secrets.ACKNOWLEDG_FORGE_SSH_USER }} + SSH_PORT: ${{ secrets.ACKNOWLEDG_FORGE_SSH_PORT || '22' }} + SSH_PRIVATE_KEY: ${{ secrets.ACKNOWLEDG_FORGE_SSH_PRIVATE_KEY }} + SSH_KNOWN_HOSTS: ${{ secrets.ACKNOWLEDG_FORGE_SSH_KNOWN_HOSTS }} + run: | + install -d -m 700 ~/.ssh + install -m 600 /dev/null ~/.ssh/deploy_key + printf '%s\n' "${SSH_PRIVATE_KEY}" > ~/.ssh/deploy_key + printf '%s\n' "${SSH_KNOWN_HOSTS}" > ~/.ssh/known_hosts + cat > ~/.ssh/config </ # tree; the publish job republishes failure artifacts from all test legs. +# The bench job runs the cachegrind benchmarks the way +# `edg-docker-bench --posture=release` does and uploads them as +# bench-results (c/ and cpp/ runs, compared by acknowledg-forge against the +# checked-in benchmarks/baselines/project/release-{c,cpp}). name: Build & Test CI @@ -277,6 +282,26 @@ jobs: path: aggregated-test-results merge-multiple: true + # Record which commit was tested so consumers of the artifact (e.g. + # acknowledg-forge) can check out the matching tree. For pull requests + # this is the merge commit, not the PR head. + - name: Record CI metadata + env: + TESTED_SHA: ${{ github.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + python3 - > aggregated-test-results/ci-meta.json <<'EOF' + import json, os + print(json.dumps({ + 'tested_sha': os.environ['TESTED_SHA'], + 'head_sha': os.environ['HEAD_SHA'], + 'run_id': int(os.environ['RUN_ID']), + 'run_attempt': int(os.environ['RUN_ATTEMPT']), + }, indent = 2)) + EOF + - name: Upload elist, elog, and .rt.tar.gz results uses: actions/upload-artifact@v7 with: @@ -284,3 +309,79 @@ jobs: path: aggregated-test-results/ if-no-files-found: warn retention-days: 14 + + bench: + name: Benchmark linux-gcc-release + if: ${{ github.event_name == 'pull_request' }} + # Benchmark infrastructure problems shouldn't fail a pull request's CI. + continue-on-error: true + runs-on: ubuntu-latest + timeout-minutes: 120 + container: + image: docker.io/edgcpp/ci-runner@sha256:73be12d128b8507f5feb9e73c25f48f14e6b041940ef165bce5fda8efe2b605c + defaults: + run: + shell: edg-container-exec "bash -eo pipefail {0}" + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + # The ci-runner image doesn't ship valgrind yet (the dev-env image + # does); container job steps run as root, so install it here. + - name: Install valgrind + shell: bash + run: dnf install -y --setopt=install_weak_deps=False valgrind + + - name: Configure linux-gcc-release + env: + EDG_BASE: ${{ github.workspace }}/bases/docker/dev-env/gcc + run: cmake --preset linux-gcc-release + + - name: Build linux-gcc-release + env: + EDG_BASE: ${{ github.workspace }}/bases/docker/dev-env/gcc + run: cmake --build build/gcc-release --target cpfe cpfe-cp + + - name: Run benchmarks + env: + EDG_BASE: ${{ github.workspace }}/bases/docker/dev-env/gcc + EDG_BENCH_RUNS_KEPT: '0' + run: | + # Use the pull request's own benchmark tooling. + export PATH="${GITHUB_WORKSPACE}/dev_tools/bin:${PATH}" + export PYTHONPATH="${GITHUB_WORKSPACE}/dev_tools/pylibs" + mkdir -p bench-results + for run in c:bin/cpfe cpp:bin/cpfe-cp; do + name="${run%%:*}" + binary="${run#*:}" + (cd build/gcc-release && source environment.sh && + edg-bench "${binary}") + latest="$(ls -1d benchmarks/runs/*/ | sort | tail -n 1)" + mv "${latest}" "bench-results/${name}" + done + + - name: Record CI metadata + env: + TESTED_SHA: ${{ github.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + python3 - > bench-results/ci-meta.json <<'EOF' + import json, os + print(json.dumps({ + 'tested_sha': os.environ['TESTED_SHA'], + 'head_sha': os.environ['HEAD_SHA'], + 'run_id': int(os.environ['RUN_ID']), + 'run_attempt': int(os.environ['RUN_ATTEMPT']), + }, indent = 2)) + EOF + + - name: Upload benchmark results + uses: actions/upload-artifact@v7 + with: + name: bench-results + path: bench-results/ + if-no-files-found: error + retention-days: 14 diff --git a/dev_tools/docker/acknowledg-forge/Dockerfile b/dev_tools/docker/acknowledg-forge/Dockerfile new file mode 100644 index 0000000000..0ae0e3088b --- /dev/null +++ b/dev_tools/docker/acknowledg-forge/Dockerfile @@ -0,0 +1,56 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# acknowledg-forge services (GitHub and GitLab), with a frozen copy of the EDG +# tooling they run and their locked Python dependencies. See +# dev_tools/services/acknowledg-forge/README.md for how to run it (rootless +# Podman; bubblewrap inside the container needs --security-opt +# unmask=/proc/*). + +FROM fedora:43 + +# The image is public: it carries no credentials or deployment configuration +# (those come from the host at run time). +LABEL org.opencontainers.image.source="https://github.com/edgcpp/compiler" +LABEL org.opencontainers.image.description="acknowledg-forge: AcknowlEDG reviews for GitHub and GitLab pull requests" +LABEL org.opencontainers.image.licenses="Apache-2.0 WITH LLVM-exception" + +RUN mkdir -p /edg/setup-scripts /edg/tools /edg/acknowledg-forge +COPY setup-scripts/* /edg/setup-scripts/ + +# Execute root based setup scripts. +RUN /edg/setup-scripts/run-all.sh && rm -rf /edg/setup-scripts + +# The frozen EDG tooling (dev_tools/bin and dev_tools/pylibs) the service runs +# (edg-rematerialize-test-run, ifc-dump); see prebuild.py. +COPY scripts-and-libs.tar.gz /edg/scripts-and-libs.tar.gz +RUN tar -xzf /edg/scripts-and-libs.tar.gz --directory=/edg/tools && \ + rm /edg/scripts-and-libs.tar.gz + +# One virtual environment per service: the locked (hash-checked) +# dependencies, then the service wheel itself. +COPY services/ /edg/acknowledg-forge/dist/ +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 +RUN for forge in github gitlab; do \ + venv="/edg/acknowledg-forge/${forge}" && \ + python3 -m venv "${venv}" && \ + "${venv}/bin/pip" install --no-cache-dir --require-hashes \ + -r "/edg/acknowledg-forge/dist/${forge}/requirements.txt" && \ + "${venv}/bin/pip" install --no-cache-dir --no-deps \ + /edg/acknowledg-forge/dist/${forge}/*.whl && \ + ln -s "${venv}/bin/acknowledg-forge-${forge}" /usr/local/bin/ ; \ + done && \ + rm -rf /edg/acknowledg-forge/dist + +RUN useradd --create-home --uid 1000 acknowledg && \ + mkdir -p /var/lib/acknowledg-forge && \ + chown acknowledg:acknowledg /var/lib/acknowledg-forge + +ENV ACKNOWLEDG_FORGE_TOOLS_DIR=/edg/tools +ENV ACKNOWLEDG_FORGE_HOME=/var/lib/acknowledg-forge + +USER acknowledg +WORKDIR /var/lib/acknowledg-forge +CMD ["acknowledg-forge-github"] diff --git a/dev_tools/docker/acknowledg-forge/prebuild.py b/dev_tools/docker/acknowledg-forge/prebuild.py new file mode 100644 index 0000000000..15626e83b1 --- /dev/null +++ b/dev_tools/docker/acknowledg-forge/prebuild.py @@ -0,0 +1,70 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +# This script is executed by build-edg-env before creation of the Docker image +# to build the image's dependencies: the frozen tooling archive, and each +# acknowledg-forge service's wheel plus its locked requirements. + +import os +import shutil +import subprocess +import sys +import tarfile + +import edgtools +import edgutil + +from pathlib import Path + +FORGES = ('github', 'gitlab') + + +def create_scripts_and_libs_tar_file() -> None: + '''Create scripts-and-libs.tar.gz (dev_tools/bin and dev_tools/pylibs).''' + tools_dir = edgtools.get_tools_dir() + tar_file_path = Path(os.getcwd()).joinpath('scripts-and-libs.tar.gz') + + with edgutil.CurrentWorkingDirectorySwap(tools_dir): + with tarfile.open(tar_file_path, 'w:gz') as tar: + tar.add('bin') + tar.add('pylibs') + + +def build_services() -> None: + '''Build each service's wheel and export its locked requirements (with + hashes) into services//. + ''' + if shutil.which('uv') is None: + edgutil.eprint('uv is required to build the acknowledg-forge image') + sys.exit(1) + services_dir = edgtools.get_tools_dir() / 'services' / 'acknowledg-forge' + for forge in FORGES: + output_dir = Path(os.getcwd(), 'services', forge) + output_dir.mkdir(parents = True) + service_dir = services_dir / forge + subprocess.run( + ['uv', 'build', '--wheel', '--out-dir', str(output_dir)], + cwd = service_dir, + check = True + ) + subprocess.run( + [ + 'uv', 'export', '--frozen', '--no-dev', '--no-emit-project', + # PostgreSQL support (asyncpg), so the image works with either + # database. + '--extra', 'postgresql', + '--format', 'requirements-txt', + '--output-file', str(output_dir / 'requirements.txt'), + ], + cwd = service_dir, + check = True + ) + + +def main() -> None: + create_scripts_and_libs_tar_file() + build_services() + +main() diff --git a/dev_tools/docker/acknowledg-forge/setup-scripts/install-software.sh b/dev_tools/docker/acknowledg-forge/setup-scripts/install-software.sh new file mode 100755 index 0000000000..4f0e146200 --- /dev/null +++ b/dev_tools/docker/acknowledg-forge/setup-scripts/install-software.sh @@ -0,0 +1,26 @@ +#!/bin/bash -e + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +packages=() + +# The service itself and its git workspaces. +packages+=("python3") +packages+=("git-core") + +# Sandboxing for commands that read pull request content. +packages+=("bubblewrap") + +# Used (sandboxed) by edg-rematerialize-test-run. +packages+=("patch") +packages+=("diffutils") + +# cg_annotate, for benchmark sections (Fedora packages it separately). +packages+=("valgrind") +packages+=("valgrind-scripts") + +dnf install -y --setopt=install_weak_deps=False "${packages[@]}" +dnf clean all diff --git a/dev_tools/docker/acknowledg-forge/setup-scripts/run-all.sh b/dev_tools/docker/acknowledg-forge/setup-scripts/run-all.sh new file mode 100755 index 0000000000..6073a2e2f3 --- /dev/null +++ b/dev_tools/docker/acknowledg-forge/setup-scripts/run-all.sh @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +/edg/setup-scripts/install-software.sh diff --git a/dev_tools/pylibs/edgacknowledg/client/__init__.py b/dev_tools/pylibs/edgacknowledg/client/__init__.py index 47ff77029f..39ca7622a0 100644 --- a/dev_tools/pylibs/edgacknowledg/client/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/__init__.py @@ -8,6 +8,7 @@ import argparse import asyncio import os +import traceback import uuid from types import TracebackType @@ -45,10 +46,11 @@ ) from edgacknowledg.client.shared import ( BackgroundWorkerPool, + Channel, ReviewClientHandler, announce_section, ) -from edgutil import get_running_event_loop +from edgutil import eprint, get_running_event_loop from edgacknowledg.client.websocket import ( ConnectionStatusListener, DEFAULT_OPEN_TIMEOUT_S, @@ -176,6 +178,11 @@ def _form_direct_ws_url(api_host: str, DEFAULT_REMOTE_API_PORT = 80 DEFAULT_REMOTE_UI_PORT = 80 +# How long to wait for the server to acknowledge a batch subscribe or +# unsubscribe before giving up on it. +SUBSCRIBE_TIMEOUT_S = 30.0 +UNSUBSCRIBE_TIMEOUT_S = 10.0 + def default_api_host() -> str: '''Return ``EDG_ACKNOWLEDG_API_HOST``, or ``DEFAULT_API_HOST``.''' @@ -377,6 +384,37 @@ async def send(self, msg: ProtocolMessage) -> None: await self._websocket.send(encode_message(msg)) +async def _reply_to_failed_request(channel: Channel, + handler: ReviewClientHandler) -> None: + '''Answer a request whose handler raised, and log why. + + The section keeps serving afterwards: one bad request (which a reviewer + can trigger) shouldn't take the whole section down. + ''' + eprint( + f"Request to section {handler.subscription_type}/{handler.slug} failed:" + ) + traceback.print_exc() + try: + await channel.send(message(RequestTags.INVALID_REQUEST)) + except (ConnectionClosed, NotConnectedError): + raise + except Exception: + pass + + +async def _await_acknowledgement(future: asyncio.Future, + timeout: float, + action: str) -> Any: + '''Wait for the server to acknowledge ``action``, up to ``timeout`` s.''' + try: + return await asyncio.wait_for(future, timeout) + except asyncio.TimeoutError: + raise ClientError( + f"timed out waiting for the server to {action}" + ) from None + + class _BatchClientReview: '''A review served over a ``BatchClientSession`` ``/batch`` connection.''' @@ -407,19 +445,23 @@ def sections(self) -> List[ReviewClientHandler]: async def add_section(self, handler: ReviewClientHandler) -> None: '''Register handler and subscribe it on the batch socket.''' - self._review.register_handler(handler) - try: - await self.subscribe_section(handler) - except Exception: - self._review.discard_handler(handler) - raise + # Serialized with reconnect restores so a section added mid-restore is + # subscribed exactly once. + async with self._client._subscriptions_lock: + self._review.register_handler(handler) + try: + await self.subscribe_section(handler) + except Exception: + self._review.discard_handler(handler) + raise async def remove_section(self, handler: ReviewClientHandler) -> None: '''Unsubscribe, then unregister.''' - if handler not in self._review.sections(): - raise SectionNotRegisteredError(handler.slug) - await self.unsubscribe_section(handler) - self._review.discard_handler(handler) + async with self._client._subscriptions_lock: + if handler not in self._review.sections(): + raise SectionNotRegisteredError(handler.slug) + await self.unsubscribe_section(handler) + self._review.discard_handler(handler) async def _run_handler_loop(self, channel: _SubscriptionChannel, @@ -438,11 +480,7 @@ async def _run_handler_loop(self, self._client.abort(ex) raise except Exception: - try: - await channel.send(message(RequestTags.INVALID_REQUEST)) - except Exception: - pass - raise + await _reply_to_failed_request(channel, handler) finally: self._client._inbounds.pop(channel.subscription_id, None) self._subscription_tasks.pop(channel.subscription_id, None) @@ -463,9 +501,24 @@ async def _drop_local_subscriptions(self) -> None: pass async def _restore_subscriptions(self) -> None: - '''Re-subscribe every registered section on a fresh batch socket.''' + '''Re-subscribe every registered section on a fresh batch socket. + + Sections already subscribed on this socket are skipped, and one section + failing to subscribe doesn't keep the rest from being restored. + ''' for handler in self._review.sections(): - await self.subscribe_section(handler) + if id(handler) in self._subscriptions: + continue + try: + await self.subscribe_section(handler) + except (ConnectionClosed, NotConnectedError, asyncio.CancelledError): + raise + except Exception: + eprint( + f"Failed to restore section " + f"{handler.subscription_type}/{handler.slug}:" + ) + traceback.print_exc() async def subscribe_section(self, handler: ReviewClientHandler) -> None: handler_key = id(handler) @@ -593,11 +646,7 @@ async def _run_handler_loop(self, IncompatibleProtocolVersion): raise except Exception: - try: - await channel.send(message(RequestTags.INVALID_REQUEST)) - except Exception: - pass - raise + await _reply_to_failed_request(channel, handler) def _signal_section_ready(self, handler_key: int) -> None: ready = self._section_first_ready.get(handler_key) @@ -775,6 +824,8 @@ def __init__(self, *, self._inbounds: Dict[uuid.UUID, asyncio.Queue] = {} self._reviews: Dict[uuid.UUID, _BatchClientReview] = {} self._session_ready = asyncio.Event() + # Held while sections are (re)subscribed or unsubscribed. + self._subscriptions_lock = asyncio.Lock() def abort(self, error: BaseException) -> None: '''End the session; ``stopped()`` will raise ``error``.''' @@ -868,8 +919,9 @@ async def _drop_subscriptions(self) -> None: async def _restore_subscriptions(self) -> None: '''Re-establish every review section on a fresh batch socket.''' - for review in list(self._reviews.values()): - await review._restore_subscriptions() + async with self._subscriptions_lock: + for review in list(self._reviews.values()): + await review._restore_subscriptions() async def _connection_loop(self, connection: ResilientWebsocket) -> None: @@ -986,7 +1038,11 @@ async def _subscribe(self, payload: Dict[str, Any]) -> uuid.UUID: self._pending_subscribe[key] = future try: await self._send(message(RequestTags.BATCH_SUBSCRIBE, value = payload)) - echo = await future + echo = await _await_acknowledgement( + future, + SUBSCRIBE_TIMEOUT_S, + f"subscribe {key[1]}/{key[2]}" + ) except Exception: self._pending_subscribe.pop(key, None) if not future.done(): @@ -1004,7 +1060,11 @@ async def _unsubscribe(self, subscription_id: uuid.UUID) -> None: RequestTags.BATCH_UNSUBSCRIBE, value = { 'subscription_id': str(subscription_id) } )) - await future + await _await_acknowledgement( + future, + UNSUBSCRIBE_TIMEOUT_S, + f"unsubscribe {subscription_id}" + ) except Exception: self._pending_unsubscribe.pop(subscription_id, None) if not future.done(): diff --git a/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py b/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py index 92b40c6ba3..119b98849f 100644 --- a/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py @@ -19,6 +19,7 @@ Dict, List, Optional, + Sequence, ) from edgacknowledg import ( @@ -144,9 +145,17 @@ def collect_all_benchmarks(run_dir: Path, return benchmark_metadatas -def get_cg_annotate_output(baseline_path: Path, run_path: Path) -> str: +def get_cg_annotate_output(baseline_path: Path, + run_path: Path, + *, + command_prefix: Sequence[str] = ()) -> str: + '''Return the ``cg_annotate`` diff of ``run_path`` against + ``baseline_path``, with ``command_prefix`` (e.g. a sandbox launcher) + prepended to the command. + ''' proc = subprocess.run( [ + *command_prefix, str(TOOL_PATH_CG_ANNOTATE), '--no-annotate', '--diff', @@ -204,13 +213,16 @@ def __init__(self, *, baseline_dir: Path, tag: str, threshold_slow: float = DEFAULT_THRESHOLD_SLOW, - threshold_fast: float = DEFAULT_THRESHOLD_FAST) -> None: + threshold_fast: float = DEFAULT_THRESHOLD_FAST, + command_prefix: Sequence[str] = ()) -> None: self.bench_home = bench_home self.run_dir = run_dir self.baseline_dir = baseline_dir self.tag = tag self.threshold_slow = threshold_slow self.threshold_fast = threshold_fast + # Prepended to external commands run on the benchmark files. + self.command_prefix = tuple(command_prefix) self.statuses_payload: Dict[str, Any] = {} self.metadata_by_id: Dict[str, BenchmarkMetadata] = {} self.refresh_statuses() @@ -290,7 +302,10 @@ async def run_bench_source_handler(channel: Channel, async def annotate_job() -> str: return await edgutil.async_to_thread( - get_cg_annotate_output, baseline_path, run_path + get_cg_annotate_output, + baseline_path, + run_path, + command_prefix = session.command_prefix ) annotate_text = await worker_pool.submit(annotate_job) diff --git a/dev_tools/pylibs/edgacknowledg/client/test/__init__.py b/dev_tools/pylibs/edgacknowledg/client/test/__init__.py index 95c619a32e..8792e79793 100644 --- a/dev_tools/pylibs/edgacknowledg/client/test/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/test/__init__.py @@ -20,6 +20,7 @@ Dict, List, Optional, + Sequence, Tuple, ) @@ -182,8 +183,14 @@ def _test_if_any_matches_exists(recordings_dir: Path, async def run_test_source_handler(channel: Channel, test_home: Path, relative_run_dir: Path, - changes_files: Dict[str, Path]) -> None: - '''Handle one test source request on ``channel``.''' + changes_files: Dict[str, Path], + *, + command_prefix: Sequence[str] = ()) -> None: + '''Handle one test source request on ``channel``. + + ``command_prefix`` is prepended to external commands run on files from the + test tree (e.g. a sandbox launcher when the tree is untrusted). + ''' request_tag, request_value, _section = decode_response(await channel.recv()) is_multi_config_run = len(changes_files) > 1 @@ -534,7 +541,7 @@ async def form_curr_diff_text(config_name: str, } )) elif path_to_show.suffix == '.ifc': - cmd_args = ['ifc-dump', str(path_to_show)] + cmd_args = [*command_prefix, 'ifc-dump', str(path_to_show)] # Execute the ifc-dump command. proc = await asyncio.create_subprocess_exec( @@ -589,11 +596,14 @@ def __init__(self, section_name: str, slug: str, test_home: Path, - root: Path) -> None: + root: Path, + *, + command_prefix: Sequence[str] = ()) -> None: self._section_name = section_name self._slug = slug self._test_home = test_home self._root = root + self._command_prefix = tuple(command_prefix) self._changes_files = edgtest.find_changes_files(root) self._relative_run_dir = root.relative_to(test_home) @@ -615,7 +625,8 @@ async def handle_request(self, channel: Channel) -> None: channel, self._test_home, self._relative_run_dir, - self._changes_files + self._changes_files, + command_prefix = self._command_prefix ) async def handle_test_edit_request( diff --git a/dev_tools/pylibs/edgacknowledg/forge/__init__.py b/dev_tools/pylibs/edgacknowledg/forge/__init__.py new file mode 100644 index 0000000000..63a108fb19 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/__init__.py @@ -0,0 +1,208 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Forge-agnostic core of acknowledg-forge. + +acknowledg-forge keeps an AcknowlEDG review alive for every open pull request +on a code forge (GitHub, GitLab, ...). This module defines the forge-neutral +data the rest of the package works with, and the protocols a forge +implementation provides: + +- ``Forge``: query pull requests and CI runs, download CI artifacts. +- ``ForgeWebhooks``: (optional) verify and parse webhook deliveries. +- ``ForgeCommenter``: (optional) leave comments on pull requests. + +Nothing outside a forge implementation knows which forge is in use. + +Requires Python 3.11+ (unlike the rest of ``edgacknowledg``, which keeps a +Python 3.6 floor); nothing outside this subpackage imports it. +''' + +import enum + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Mapping, Optional, Protocol, Sequence, Union + + +class PullRequestState(enum.StrEnum): + OPEN = 'open' + CLOSED = 'closed' + MERGED = 'merged' + + +class CIStatus(enum.StrEnum): + PENDING = 'pending' + RUNNING = 'running' + COMPLETED = 'completed' + + +class CIConclusion(enum.StrEnum): + SUCCESS = 'success' + FAILURE = 'failure' + CANCELLED = 'cancelled' + OTHER = 'other' + + +@dataclass(frozen = True) +class PullRequestInfo: + '''A pull request (merge request, ...) as reported by the forge.''' + # Forge-specific repository identifier, e.g. 'owner/name' or a GitLab + # project path. + repository: str + number: int + state: PullRequestState + title: str + author: str + url: str + # Branch the pull request targets, and its current commit. + base_ref: str + base_sha: str + head_sha: str + # Ref in the *base* repository that holds the head commit (e.g. + # 'refs/pull/7/head'), so pull requests from forks need no extra remotes. + fetch_ref: str + updated_at: datetime + + +@dataclass(frozen = True) +class ArtifactInfo: + '''A file produced by a CI run.''' + name: str + id: str + size: int + # Content digest reported by the forge, if any (e.g. 'sha256:...'). + digest: Optional[str] + expired: bool + + +@dataclass(frozen = True) +class CIRunInfo: + '''One attempt of the configured CI workflow for a commit.''' + run_id: str + attempt: int + head_sha: str + status: CIStatus + conclusion: Optional[CIConclusion] + completed_at: Optional[datetime] + url: str + artifacts: tuple[ArtifactInfo, ...] + + def artifact(self, name: str) -> Optional[ArtifactInfo]: + '''Return the artifact called ``name``, if the run has one.''' + for artifact in self.artifacts: + if artifact.name == name: + return artifact + return None + + +@dataclass(frozen = True) +class PullRequestTouched: + '''Something about a pull request may have changed.''' + repository: str + number: int + + +@dataclass(frozen = True) +class CIRunTouched: + '''A CI run for the given commit may have changed.''' + repository: str + head_sha: str + + +# Webhook events carry only identity; the service re-queries the forge for +# the actual state, so a delivery's payload is never trusted. +ForgeEvent = Union[PullRequestTouched, CIRunTouched] + + +class ForgeError(Exception): + '''Raised when a forge request fails.''' + + +class ArtifactTooLargeError(ForgeError): + '''Raised when an artifact exceeds the configured size limit.''' + + +class Forge(Protocol): + '''Read access to a forge.''' + + @property + def name(self) -> str: + '''Short, filesystem-safe forge name, e.g. 'github'.''' + ... + + def clone_url(self, repository: str) -> str: + ... + + def git_http_headers(self) -> Sequence[str]: + '''HTTP headers (e.g. authorization) git needs to fetch from the forge.''' + ... + + async def list_open_pull_requests(self, + repository: str) -> list[PullRequestInfo]: + ... + + async def get_pull_request(self, + repository: str, + number: int) -> PullRequestInfo: + ... + + async def latest_ci_run(self, + pull_request: PullRequestInfo + ) -> Optional[CIRunInfo]: + '''Return the newest attempt of the configured CI workflow for the pull + request's head commit, or None if it hasn't run (or CI isn't tracked). + ''' + ... + + async def download_artifact(self, + repository: str, + artifact: ArtifactInfo, + destination: Path, + *, + max_bytes: int) -> None: + '''Download ``artifact`` as a zip archive to ``destination``. + + Raises ``ArtifactTooLargeError`` past ``max_bytes``. + ''' + ... + + async def aclose(self) -> None: + ... + + +class ForgeWebhooks(Protocol): + '''Webhook support for a forge.''' + + def verify(self, headers: Mapping[str, str], body: bytes) -> bool: + '''Return whether the delivery is authentic.''' + ... + + def parse(self, + headers: Mapping[str, str], + body: bytes) -> list[ForgeEvent]: + '''Return the events in an authentic delivery (possibly none).''' + ... + + +class ForgeCommenter(Protocol): + '''Comment access to a forge, for the service account.''' + + async def post_comment(self, + repository: str, + number: int, + body: str) -> str: + '''Post a comment and return its forge id.''' + ... + + async def find_comment(self, + repository: str, + number: int, + marker: str) -> Optional[str]: + '''Return the id of a comment by the service account containing + ``marker``, if there is one. + ''' + ... diff --git a/dev_tools/pylibs/edgacknowledg/forge/announcer.py b/dev_tools/pylibs/edgacknowledg/forge/announcer.py new file mode 100644 index 0000000000..5129872021 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/announcer.py @@ -0,0 +1,204 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Announcing (optional): one comment per meaningful change in what's served. + +A pull request is commented on only when it has *settled* (CI for its head +has finished, the settle timeout passed, or CI isn't tracked), so the +sections one push produces are announced together. Comments are driven by +a fingerprint of the served sections' content, so restarts, rebuilds, +closing, and reopening with the same results post nothing. + +Posting is idempotent: the fingerprint is recorded as pending first, and +each comment carries a hidden marker, so after a crash between posting and +recording, the existing comment is found instead of posting a duplicate. +''' + +import hashlib +import json +import logging + +from datetime import datetime +from typing import Callable, Optional, Sequence + +from edgacknowledg.forge import ( + CIRunInfo, + CIStatus, + ForgeCommenter, +) +from edgacknowledg.forge.config import ForgeServiceConfig +from edgacknowledg.forge.db import ForgeStore, PullRequestRecord, SectionRecord +from edgacknowledg.forge.ingest import utc_now +from edgacknowledg.forge.publisher import Publisher + +_log = logging.getLogger(__name__) + + +def served_fingerprint(sections: Sequence[SectionRecord]) -> str: + '''Identify what a set of sections shows (names and content).''' + pairs = sorted( + (section.name, section.content_fingerprint) for section in sections + ) + return hashlib.sha256(json.dumps(pairs).encode()).hexdigest() + + +def comment_marker(review_id: object, fingerprint: str) -> str: + return f"" + + +def render_comment(record: PullRequestRecord, + sections: Sequence[SectionRecord], + previous: dict[str, str], + ci_run: Optional[CIRunInfo], + marker: str, + section_url: Callable[[SectionRecord], Optional[str]] + ) -> str: + '''Render an announcement. Only fixed text, commit hashes, and + service-generated URLs go in; nothing a pull request author controls. + ''' + lines = [ + marker, + f"**AcknowlEDG review results updated** for " + f"`{record.info.head_sha[:12]}`:", + '', + ] + current_names = set() + for section in sorted(sections, key = lambda s: s.name): + current_names.add(section.name) + url = section_url(section) + label = f"[{section.name}]({url})" if url else section.name + if section.name not in previous: + change = 'new' + elif previous[section.name] != section.content_fingerprint: + change = 'updated' + else: + change = 'unchanged' + lines.append(f"- {label} ({change})") + for name in sorted(set(previous) - current_names): + lines.append(f"- ~~{name}~~ (no longer applies)") + if len(sections) == 0 and len(previous) == 0: + lines.append('- No results to review.') + + if (ci_run is not None and ci_run.head_sha == record.info.head_sha and + ci_run.status == CIStatus.COMPLETED and ci_run.conclusion is not None): + lines += ['', f"CI [{ci_run.conclusion.value}]({ci_run.url})."] + return '\n'.join(lines) + '\n' + + +class Announcer: + '''Posts batched pull request comments when served results change.''' + + def __init__(self, + *, + store: ForgeStore, + forge_name: str, + commenter: ForgeCommenter, + config: ForgeServiceConfig, + publisher: Publisher, + busy: Callable[[], frozenset[int]], + clock: Callable[[], datetime] = utc_now) -> None: + self._store = store + self._forge_name = forge_name + self._commenter = commenter + self._config = config + self._publisher = publisher + self._busy = busy + self._clock = clock + self._adopt_before: Optional[datetime] = None + + async def start(self) -> None: + '''On a fresh database, adopt (without commenting) the results of pull + requests last updated before now, instead of announcing everything that + was already open. + ''' + if not await self._store.has_announcements(self._forge_name): + self._adopt_before = self._clock() + _log.info('fresh announcement state: adopting existing pull requests') + + async def sweep(self) -> None: + '''Announce changes for every settled, fully processed pull request.''' + busy = self._busy() + for record in await self._store.open_pull_requests(self._forge_name): + if (record.id in busy or record.is_dirty or + record.info.base_ref not in self._config.base_branches): + continue + try: + await self._maybe_announce(record) + except Exception: + _log.exception('announcing for %s#%d failed', + record.info.repository, record.info.number) + + async def _settled(self, record: PullRequestRecord) -> bool: + if not self._config.ci_tracked: + return True + if self._clock() >= record.settle_deadline: + return True + run = await self._store.ci_run(record.id) + return ( + run is not None and + run.head_sha == record.info.head_sha and + run.status == CIStatus.COMPLETED + ) + + async def _maybe_announce(self, record: PullRequestRecord) -> None: + sections = await self._store.sections(record.id) + fingerprint = served_fingerprint(sections) + announcement = await self._store.announcement(record.id) + if announcement is not None and announcement.fingerprint == fingerprint: + return + if announcement is None and len(sections) == 0: + # Nothing has been served yet, so there's nothing to announce. + return + if not await self._settled(record): + return + + section_map = { + section.name: section.content_fingerprint for section in sections + } + info = record.info + if (announcement is None and self._adopt_before is not None and + info.updated_at < self._adopt_before): + await self._store.record_announcement( + record.id, + fingerprint, + section_map, + comment_id = None, + posted_at = None + ) + return + + marker = comment_marker(record.review_id, fingerprint) + comment_id = None + if (announcement is not None and + announcement.pending_fingerprint == fingerprint): + # A previous attempt may have posted before failing to record it. + comment_id = await self._commenter.find_comment( + info.repository, + info.number, + marker + ) + if comment_id is None: + await self._store.set_pending_announcement(record.id, fingerprint) + body = render_comment( + record, + sections, + announcement.sections if announcement is not None else {}, + await self._store.ci_run(record.id), + marker, + lambda section: self._publisher.section_url(record.review_id, section) + ) + comment_id = await self._commenter.post_comment( + info.repository, + info.number, + body + ) + _log.info('commented on %s#%d', info.repository, info.number) + await self._store.record_announcement( + record.id, + fingerprint, + section_map, + comment_id = comment_id, + posted_at = self._clock() + ) diff --git a/dev_tools/pylibs/edgacknowledg/forge/config.py b/dev_tools/pylibs/edgacknowledg/forge/config.py new file mode 100644 index 0000000000..1d654c4f19 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/config.py @@ -0,0 +1,201 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''acknowledg-forge configuration, read from the environment.''' + +import os + +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Optional + +from edgacknowledg.client import ( + default_api_host, + default_api_port, + default_ui_host, + default_ui_port, +) + +ENV_PREFIX = 'ACKNOWLEDG_FORGE_' + + +class ConfigError(Exception): + '''Raised for a missing or malformed setting.''' + + +def read_secret(credential_name: str, + env_name: str, + *, + environ: Mapping[str, str] = os.environ) -> Optional[str]: + '''Return a secret, preferring a systemd credential over the environment. + + Credentials (``LoadCredential=`` / ``LoadCredentialEncrypted=``) keep the + secret out of the process environment; ``env_name`` is the fallback for + local development. + ''' + credentials_dir = environ.get('CREDENTIALS_DIRECTORY') + if credentials_dir is not None: + path = Path(credentials_dir, credential_name) + if path.is_file(): + return path.read_text(encoding = 'utf-8').rstrip('\r\n') + return environ.get(env_name) + + +@dataclass(frozen = True) +class ForgeServiceConfig: + '''Settings shared by every forge service.''' + # Root for clones, worktrees, and (by default) the database. + home: Path + database_url: str + repositories: tuple[str, ...] + # Only pull requests targeting these branches are served, so tooling only + # ever runs from trusted, main-line commits. + base_branches: frozenset[str] + # Forge API base URL override (e.g. GitHub Enterprise, self-hosted GitLab). + api_url: Optional[str] + # Seconds between polls; 0 polls once at startup only. + poll_interval: float + # 'host:port' to serve webhooks on; None runs no web server at all. + webhook_bind: Optional[str] + comments: bool + # CI workflow (GitHub workflow file or id; GitLab: unused) whose runs are + # tracked; None disables CI tracking. + ci_workflow: Optional[str] + # Seconds to wait for CI before a pull request counts as settled anyway. + settle_timeout: float + test_artifact: str + bench_artifact: str + max_artifact_bytes: int + max_extracted_bytes: int + max_concurrency: int + rematerialize_timeout: float + unsandboxed: bool + # A dev_tools-style directory (bin/ and pylibs/) with the EDG tooling the + # service runs (edg-rematerialize-test-run, ifc-dump): a frozen, reviewed + # copy (the container image embeds one), never the pull request's. + tools_dir: Path + forge_token: Optional[str] + webhook_secret: Optional[str] + acknowledg_api_host: str + acknowledg_api_port: int + acknowledg_api_key: Optional[str] + acknowledg_ui_host: str + acknowledg_ui_port: int + + @classmethod + def from_environment(cls, + environ: Mapping[str, str] = os.environ + ) -> 'ForgeServiceConfig': + def get(name: str) -> Optional[str]: + value = environ.get(ENV_PREFIX + name) + if value is None or value.strip() == '': + return None + return value.strip() + + def get_number(name: str, default: float) -> float: + value = get(name) + if value is None: + return default + try: + number = float(value) + except ValueError: + raise ConfigError(f"{ENV_PREFIX}{name} must be a number") from None + if number < 0: + raise ConfigError(f"{ENV_PREFIX}{name} must not be negative") + return number + + def get_flag(name: str) -> bool: + value = get(name) + if value is None: + return False + if value.lower() in ('1', 'true', 'yes', 'on'): + return True + if value.lower() in ('0', 'false', 'no', 'off'): + return False + raise ConfigError(f"{ENV_PREFIX}{name} must be a boolean (1/0)") + + def get_list(name: str) -> tuple[str, ...]: + value = get(name) + if value is None: + return () + return tuple(item.strip() for item in value.split(',') if item.strip()) + + home_value = get('HOME') + if home_value is None: + raise ConfigError(f"{ENV_PREFIX}HOME must be set") + home = Path(home_value).absolute() + + repositories = get_list('REPOSITORIES') + if len(repositories) == 0: + raise ConfigError(f"{ENV_PREFIX}REPOSITORIES must list a repository") + + tools_value = get('TOOLS_DIR') + if tools_value is None: + raise ConfigError(f"{ENV_PREFIX}TOOLS_DIR must be set") + tools_dir = Path(tools_value).absolute() + if not (tools_dir / 'bin' / 'edg-rematerialize-test-run').is_file(): + raise ConfigError( + f"{ENV_PREFIX}TOOLS_DIR ({tools_dir}) has no " + f"bin/edg-rematerialize-test-run" + ) + + max_concurrency = int(get_number('MAX_CONCURRENCY', 2)) + if max_concurrency < 1: + raise ConfigError(f"{ENV_PREFIX}MAX_CONCURRENCY must be at least 1") + + return cls( + home = home, + # A credential, as a remote database's URL holds a password. + database_url = ( + read_secret( + 'database-url', + f"{ENV_PREFIX}DATABASE_URL", + environ = environ + ) or f"sqlite+aiosqlite:///{home / 'state.db'}" + ), + repositories = repositories, + base_branches = frozenset(get_list('BASE_BRANCHES') or ('main',)), + api_url = get('API_URL'), + poll_interval = get_number('POLL_INTERVAL', 300), + webhook_bind = get('WEBHOOK_BIND'), + comments = get_flag('COMMENTS'), + ci_workflow = get('CI_WORKFLOW'), + settle_timeout = get_number('SETTLE_TIMEOUT', 2 * 60 * 60), + test_artifact = get('TEST_ARTIFACT') or 'test-results-all', + bench_artifact = get('BENCH_ARTIFACT') or 'bench-results', + max_artifact_bytes = int( + get_number('MAX_ARTIFACT_BYTES', 256 * 1024 * 1024) + ), + max_extracted_bytes = int( + get_number('MAX_EXTRACTED_BYTES', 2 * 1024 * 1024 * 1024) + ), + max_concurrency = max_concurrency, + rematerialize_timeout = get_number('REMATERIALIZE_TIMEOUT', 15 * 60), + unsandboxed = get_flag('UNSANDBOXED'), + tools_dir = tools_dir, + forge_token = read_secret( + 'forge-token', + f"{ENV_PREFIX}TOKEN", + environ = environ + ), + webhook_secret = read_secret( + 'webhook-secret', + f"{ENV_PREFIX}WEBHOOK_SECRET", + environ = environ + ), + acknowledg_api_host = default_api_host(), + acknowledg_api_port = default_api_port(), + acknowledg_api_key = read_secret( + 'acknowledg-api-key', + 'EDG_ACKNOWLEDG_API_KEY', + environ = environ + ), + acknowledg_ui_host = default_ui_host(), + acknowledg_ui_port = default_ui_port(), + ) + + @property + def ci_tracked(self) -> bool: + return self.ci_workflow is not None diff --git a/dev_tools/pylibs/edgacknowledg/forge/db/__init__.py b/dev_tools/pylibs/edgacknowledg/forge/db/__init__.py new file mode 100644 index 0000000000..a64970de9e --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/db/__init__.py @@ -0,0 +1,20 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''acknowledg-forge state database (SQLAlchemy; SQLite or PostgreSQL).''' + +from edgacknowledg.forge.db.store import ( + AnnouncementRecord, + ForgeStore, + PullRequestRecord, + SectionRecord, +) + +__all__ = [ + 'AnnouncementRecord', + 'ForgeStore', + 'PullRequestRecord', + 'SectionRecord', +] diff --git a/dev_tools/pylibs/edgacknowledg/forge/db/migrations/__init__.py b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/__init__.py new file mode 100644 index 0000000000..45049beeda --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/__init__.py @@ -0,0 +1,6 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Alembic migrations for the acknowledg-forge database.''' diff --git a/dev_tools/pylibs/edgacknowledg/forge/db/migrations/env.py b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/env.py new file mode 100644 index 0000000000..399af99196 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/env.py @@ -0,0 +1,40 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Alembic environment: migrates the connection ForgeStore passes in. + +Run through ``ForgeStore.open``; there is no standalone alembic.ini. To +write a new revision, run from this directory, e.g.: + + alembic -x url=sqlite:///new.db revision --autogenerate -m "..." +''' + +from alembic import context +from sqlalchemy import create_engine +from sqlalchemy.engine import Connection + +from edgacknowledg.forge.db.models import Base + +config = context.config + + +def _run(connection: Connection) -> None: + context.configure( + connection = connection, + target_metadata = Base.metadata, + # SQLite can only alter tables by copying them. + render_as_batch = True, + ) + with context.begin_transaction(): + context.run_migrations() + + +connection = config.attributes.get('connection') +if connection is not None: + _run(connection) +else: + url = context.get_x_argument(as_dictionary = True)['url'] + with create_engine(url).connect() as standalone_connection: + _run(standalone_connection) diff --git a/dev_tools/pylibs/edgacknowledg/forge/db/migrations/script.py.mako b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/script.py.mako new file mode 100644 index 0000000000..712171cbfc --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/script.py.mako @@ -0,0 +1,28 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +''' + +import sqlalchemy as sa + +from alembic import op +${imports if imports else ""} +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/dev_tools/pylibs/edgacknowledg/forge/db/migrations/versions/0001_initial_schema.py b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000000..611f08fce8 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/versions/0001_initial_schema.py @@ -0,0 +1,111 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Initial schema. + +Revision ID: 0001 +Revises: +Create Date: 2026-10-15 +''' + +import sqlalchemy as sa + +from alembic import op + +revision = '0001' +down_revision = None +branch_labels = None +depends_on = None + + +def _pull_request_fk() -> sa.ForeignKeyConstraint: + return sa.ForeignKeyConstraint( + ['pull_request_id'], + ['pull_requests.id'], + ondelete = 'CASCADE' + ) + + +def upgrade() -> None: + # Datetimes are stored as naive UTC (see models.UTCDateTime). + op.create_table( + 'pull_requests', + sa.Column('id', sa.Integer(), nullable = False), + sa.Column('forge', sa.String(length = 32), nullable = False), + sa.Column('repository', sa.String(length = 255), nullable = False), + sa.Column('number', sa.Integer(), nullable = False), + sa.Column('review_id', sa.Uuid(), nullable = False), + sa.Column('state', sa.String(length = 16), nullable = False), + sa.Column('title', sa.Text(), nullable = False), + sa.Column('author', sa.String(length = 255), nullable = False), + sa.Column('url', sa.Text(), nullable = False), + sa.Column('base_ref', sa.String(length = 255), nullable = False), + sa.Column('base_sha', sa.String(length = 64), nullable = False), + sa.Column('head_sha', sa.String(length = 64), nullable = False), + sa.Column('fetch_ref', sa.String(length = 255), nullable = False), + sa.Column('forge_updated_at', sa.DateTime(), nullable = False), + sa.Column('head_changed_at', sa.DateTime(), nullable = False), + sa.Column('settle_deadline', sa.DateTime(), nullable = False), + sa.Column('generation', sa.Integer(), nullable = False), + sa.Column('processed_generation', sa.Integer(), nullable = False), + sa.Column('error_count', sa.Integer(), nullable = False), + sa.Column('next_attempt_at', sa.DateTime(), nullable = True), + sa.Column('last_error', sa.Text(), nullable = True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('forge', 'repository', 'number'), + sa.UniqueConstraint('review_id'), + ) + op.create_table( + 'announcements', + sa.Column('pull_request_id', sa.Integer(), nullable = False), + sa.Column('fingerprint', sa.String(length = 128), nullable = True), + sa.Column('sections', sa.JSON(), nullable = False), + sa.Column('pending_fingerprint', sa.String(length = 128), nullable = True), + sa.Column('last_comment_id', sa.String(length = 64), nullable = True), + sa.Column('last_posted_at', sa.DateTime(), nullable = True), + _pull_request_fk(), + sa.PrimaryKeyConstraint('pull_request_id'), + ) + op.create_table( + 'ci_runs', + sa.Column('pull_request_id', sa.Integer(), nullable = False), + sa.Column('run_id', sa.String(length = 64), nullable = False), + sa.Column('attempt', sa.Integer(), nullable = False), + sa.Column('head_sha', sa.String(length = 64), nullable = False), + sa.Column('status', sa.String(length = 16), nullable = False), + sa.Column('conclusion', sa.String(length = 16), nullable = True), + sa.Column('completed_at', sa.DateTime(), nullable = True), + sa.Column('url', sa.Text(), nullable = False), + sa.Column('artifacts', sa.JSON(), nullable = False), + _pull_request_fk(), + sa.PrimaryKeyConstraint('pull_request_id'), + ) + op.create_table( + 'sections', + sa.Column('id', sa.Integer(), nullable = False), + sa.Column('pull_request_id', sa.Integer(), nullable = False), + sa.Column('kind', sa.String(length = 32), nullable = False), + sa.Column('name', sa.String(length = 255), nullable = False), + sa.Column('slug', sa.String(length = 255), nullable = False), + sa.Column('source_key', sa.Text(), nullable = False), + sa.Column( + 'content_fingerprint', + sa.String(length = 128), + nullable = False + ), + sa.Column('root', sa.Text(), nullable = False), + sa.Column('home', sa.Text(), nullable = False), + sa.Column('details', sa.JSON(), nullable = False), + _pull_request_fk(), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('pull_request_id', 'slug'), + ) + + +def downgrade() -> None: + op.drop_table('sections') + op.drop_table('ci_runs') + op.drop_table('announcements') + op.drop_table('pull_requests') diff --git a/dev_tools/pylibs/edgacknowledg/forge/db/migrations/versions/__init__.py b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/versions/__init__.py new file mode 100644 index 0000000000..026b00e24f --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/db/migrations/versions/__init__.py @@ -0,0 +1,5 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + diff --git a/dev_tools/pylibs/edgacknowledg/forge/db/models.py b/dev_tools/pylibs/edgacknowledg/forge/db/models.py new file mode 100644 index 0000000000..6b06a251be --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/db/models.py @@ -0,0 +1,173 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''SQLAlchemy models for acknowledg-forge state. + +Only portable column types are used so the same models work on SQLite and +PostgreSQL. Schema changes go through Alembic (``migrations/``). +''' + +import uuid + +from datetime import datetime, timezone +from typing import Any, Optional + +from sqlalchemy import ( + JSON, + DateTime, + ForeignKey, + Integer, + String, + Text, + TypeDecorator, + UniqueConstraint, + Uuid, +) +from sqlalchemy.engine import Dialect +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class UTCDateTime(TypeDecorator[datetime]): + '''A timezone-aware UTC datetime on every backend. + + SQLite has no timezone support, so values are stored as naive UTC and + made aware again when loaded. + ''' + impl = DateTime + cache_ok = True + + def process_bind_param(self, + value: Optional[datetime], + dialect: Dialect) -> Optional[datetime]: + if value is None: + return None + if value.tzinfo is None: + raise ValueError('naive datetimes are not accepted') + return value.astimezone(timezone.utc).replace(tzinfo = None) + + def process_result_value(self, + value: Optional[datetime], + dialect: Dialect) -> Optional[datetime]: + if value is None: + return None + return value.replace(tzinfo = timezone.utc) + + +class Base(DeclarativeBase): + pass + + +class PullRequest(Base): + '''A pull request the service knows about, open or not.''' + __tablename__ = 'pull_requests' + __table_args__ = (UniqueConstraint('forge', 'repository', 'number'),) + + id: Mapped[int] = mapped_column(Integer, primary_key = True) + forge: Mapped[str] = mapped_column(String(32)) + repository: Mapped[str] = mapped_column(String(255)) + number: Mapped[int] = mapped_column(Integer) + # Assigned once; reused if the pull request is reopened. + review_id: Mapped[uuid.UUID] = mapped_column(Uuid, unique = True) + state: Mapped[str] = mapped_column(String(16)) + title: Mapped[str] = mapped_column(Text) + author: Mapped[str] = mapped_column(String(255)) + url: Mapped[str] = mapped_column(Text) + base_ref: Mapped[str] = mapped_column(String(255)) + base_sha: Mapped[str] = mapped_column(String(64)) + head_sha: Mapped[str] = mapped_column(String(64)) + fetch_ref: Mapped[str] = mapped_column(String(255)) + forge_updated_at: Mapped[datetime] = mapped_column(UTCDateTime) + head_changed_at: Mapped[datetime] = mapped_column(UTCDateTime) + # When the pull request counts as settled even without a finished CI run. + settle_deadline: Mapped[datetime] = mapped_column(UTCDateTime) + # Bumped by ingestion on every material change; the pull request needs + # processing while generation > processed_generation. + generation: Mapped[int] = mapped_column(Integer, default = 1) + processed_generation: Mapped[int] = mapped_column(Integer, default = 0) + error_count: Mapped[int] = mapped_column(Integer, default = 0) + next_attempt_at: Mapped[Optional[datetime]] = mapped_column( + UTCDateTime, + nullable = True + ) + last_error: Mapped[Optional[str]] = mapped_column(Text, nullable = True) + + +class CIRun(Base): + '''The latest tracked CI run attempt for a pull request's head.''' + __tablename__ = 'ci_runs' + + pull_request_id: Mapped[int] = mapped_column( + ForeignKey('pull_requests.id', ondelete = 'CASCADE'), + primary_key = True + ) + run_id: Mapped[str] = mapped_column(String(64)) + attempt: Mapped[int] = mapped_column(Integer) + head_sha: Mapped[str] = mapped_column(String(64)) + status: Mapped[str] = mapped_column(String(16)) + conclusion: Mapped[Optional[str]] = mapped_column( + String(16), + nullable = True + ) + completed_at: Mapped[Optional[datetime]] = mapped_column( + UTCDateTime, + nullable = True + ) + url: Mapped[str] = mapped_column(Text) + # [{name, id, size, digest, expired}, ...] + artifacts: Mapped[list[dict[str, Any]]] = mapped_column(JSON) + + +class Section(Base): + '''A review section currently being served for a pull request.''' + __tablename__ = 'sections' + __table_args__ = (UniqueConstraint('pull_request_id', 'slug'),) + + id: Mapped[int] = mapped_column(Integer, primary_key = True) + pull_request_id: Mapped[int] = mapped_column( + ForeignKey('pull_requests.id', ondelete = 'CASCADE') + ) + kind: Mapped[str] = mapped_column(String(32)) + name: Mapped[str] = mapped_column(String(255)) + slug: Mapped[str] = mapped_column(String(255)) + # Identifies the inputs the section was built from; a section is rebuilt + # when its desired source key changes. + source_key: Mapped[str] = mapped_column(Text) + # Identifies what the section shows; drives comment decisions. + content_fingerprint: Mapped[str] = mapped_column(String(128)) + # Directory the section serves, and the test or bench home it is under. + root: Mapped[str] = mapped_column(Text) + home: Mapped[str] = mapped_column(Text) + # Section-specific extras (e.g. the bench baseline directory and tag). + details: Mapped[dict[str, Any]] = mapped_column(JSON, default = dict) + + +class Announcement(Base): + '''What the service last announced in a pull request comment.''' + __tablename__ = 'announcements' + + pull_request_id: Mapped[int] = mapped_column( + ForeignKey('pull_requests.id', ondelete = 'CASCADE'), + primary_key = True + ) + # Served-state fingerprint and {section name: content fingerprint} as of + # the last comment (or adoption, which sets them without commenting). + fingerprint: Mapped[Optional[str]] = mapped_column( + String(128), + nullable = True + ) + sections: Mapped[dict[str, str]] = mapped_column(JSON, default = dict) + # Set before posting so a crash mid-post can be reconciled on retry. + pending_fingerprint: Mapped[Optional[str]] = mapped_column( + String(128), + nullable = True + ) + last_comment_id: Mapped[Optional[str]] = mapped_column( + String(64), + nullable = True + ) + last_posted_at: Mapped[Optional[datetime]] = mapped_column( + UTCDateTime, + nullable = True + ) diff --git a/dev_tools/pylibs/edgacknowledg/forge/db/store.py b/dev_tools/pylibs/edgacknowledg/forge/db/store.py new file mode 100644 index 0000000000..40dc78205f --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/db/store.py @@ -0,0 +1,567 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Database access for acknowledg-forge. + +Every query lives here. Callers get immutable records rather than ORM +objects, so nothing lazy-loads outside a session and the rest of the service +doesn't depend on SQLAlchemy. +''' + +import uuid + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Optional, Sequence + +from sqlalchemy import delete, event, func, select +from sqlalchemy.engine import Connection, make_url +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.pool import StaticPool + +from edgacknowledg.forge import ( + ArtifactInfo, + CIConclusion, + CIRunInfo, + CIStatus, + PullRequestInfo, + PullRequestState, +) +from edgacknowledg.forge.db.models import ( + Announcement, + CIRun, + PullRequest, + Section, +) + +_MIGRATIONS_DIR = Path(__file__).parent / 'migrations' + + +@dataclass(frozen = True) +class PullRequestRecord: + id: int + forge: str + review_id: uuid.UUID + info: PullRequestInfo + head_changed_at: datetime + settle_deadline: datetime + generation: int + processed_generation: int + error_count: int + next_attempt_at: Optional[datetime] + last_error: Optional[str] + + @property + def is_dirty(self) -> bool: + return self.generation > self.processed_generation + + +@dataclass(frozen = True) +class SectionRecord: + kind: str + name: str + slug: str + source_key: str + content_fingerprint: str + root: Path + home: Path + details: dict[str, Any] = field(default_factory = dict) + + +@dataclass(frozen = True) +class AnnouncementRecord: + fingerprint: Optional[str] + sections: dict[str, str] + pending_fingerprint: Optional[str] + last_comment_id: Optional[str] + last_posted_at: Optional[datetime] + + +def _pull_request_record(row: PullRequest) -> PullRequestRecord: + return PullRequestRecord( + id = row.id, + forge = row.forge, + review_id = row.review_id, + info = PullRequestInfo( + repository = row.repository, + number = row.number, + state = PullRequestState(row.state), + title = row.title, + author = row.author, + url = row.url, + base_ref = row.base_ref, + base_sha = row.base_sha, + head_sha = row.head_sha, + fetch_ref = row.fetch_ref, + updated_at = row.forge_updated_at, + ), + head_changed_at = row.head_changed_at, + settle_deadline = row.settle_deadline, + generation = row.generation, + processed_generation = row.processed_generation, + error_count = row.error_count, + next_attempt_at = row.next_attempt_at, + last_error = row.last_error, + ) + + +def _artifacts_to_json(artifacts: Sequence[ArtifactInfo] + ) -> list[dict[str, Any]]: + return [ + { + 'name': artifact.name, + 'id': artifact.id, + 'size': artifact.size, + 'digest': artifact.digest, + 'expired': artifact.expired, + } + for artifact in artifacts + ] + + +def _ci_run_info(row: CIRun) -> CIRunInfo: + return CIRunInfo( + run_id = row.run_id, + attempt = row.attempt, + head_sha = row.head_sha, + status = CIStatus(row.status), + conclusion = ( + CIConclusion(row.conclusion) if row.conclusion is not None else None + ), + completed_at = row.completed_at, + url = row.url, + artifacts = tuple( + ArtifactInfo( + name = item['name'], + id = item['id'], + size = item['size'], + digest = item['digest'], + expired = item['expired'], + ) + for item in row.artifacts + ), + ) + + +def _section_record(row: Section) -> SectionRecord: + return SectionRecord( + kind = row.kind, + name = row.name, + slug = row.slug, + source_key = row.source_key, + content_fingerprint = row.content_fingerprint, + root = Path(row.root), + home = Path(row.home), + details = dict(row.details), + ) + + +def _bump_generation(row: PullRequest) -> None: + '''Mark ``row`` as needing processing, retrying immediately.''' + row.generation += 1 + row.error_count = 0 + row.next_attempt_at = None + row.last_error = None + + +def _run_migrations(connection: Connection) -> None: + # Alembic is only needed at startup; keep it out of module import. + from alembic import command + from alembic.config import Config + + config = Config() + config.set_main_option('script_location', str(_MIGRATIONS_DIR)) + config.attributes['connection'] = connection + command.upgrade(config, 'head') + + +class ForgeStore: + '''Async access to the acknowledg-forge database.''' + + def __init__(self, engine: AsyncEngine) -> None: + self._engine = engine + self._sessions = async_sessionmaker(engine, expire_on_commit = False) + + @classmethod + async def open(cls, url: str) -> 'ForgeStore': + '''Connect to ``url`` and bring its schema up to date.''' + parsed_url = make_url(url) + is_sqlite = parsed_url.get_backend_name() == 'sqlite' + engine_kwargs: dict[str, Any] = {} + if is_sqlite and parsed_url.database in (None, '', ':memory:'): + # An in-memory database exists per connection; share one. + engine_kwargs['poolclass'] = StaticPool + engine = create_async_engine(url, **engine_kwargs) + + if is_sqlite: + def configure_sqlite(dbapi_connection: Any, _record: Any) -> None: + cursor = dbapi_connection.cursor() + cursor.execute('PRAGMA foreign_keys = ON') + cursor.execute('PRAGMA journal_mode = WAL') + cursor.execute('PRAGMA busy_timeout = 10000') + cursor.close() + + event.listen(engine.sync_engine, 'connect', configure_sqlite) + + async with engine.begin() as connection: + await connection.run_sync(_run_migrations) + return cls(engine) + + async def aclose(self) -> None: + await self._engine.dispose() + + def _session(self) -> AsyncSession: + return self._sessions() + + async def _find_row(self, + session: AsyncSession, + forge: str, + repository: str, + number: int) -> Optional[PullRequest]: + result = await session.execute( + select(PullRequest) + .where(PullRequest.forge == forge) + .where(PullRequest.repository == repository) + .where(PullRequest.number == number) + .with_for_update() + ) + return result.scalar_one_or_none() + + async def upsert_pull_request(self, + forge: str, + info: PullRequestInfo, + *, + now: datetime, + settle_timeout: float) -> bool: + '''Record the forge's view of a pull request. + + Returns whether anything material changed (and the pull request now + needs processing). A moving base branch alone isn't material: the + merge-base is recomputed whenever the pull request is processed anyway, + and treating it as material would reprocess every pull request on every + push to the base branch. + ''' + try: + return await self._upsert_pull_request(forge, info, now, settle_timeout) + except IntegrityError: + # Another task inserted the same pull request first; update it. + return await self._upsert_pull_request(forge, info, now, settle_timeout) + + async def _upsert_pull_request(self, + forge: str, + info: PullRequestInfo, + now: datetime, + settle_timeout: float) -> bool: + settle_deadline = now + timedelta(seconds = settle_timeout) + async with self._session() as session, session.begin(): + row = await self._find_row(session, forge, info.repository, info.number) + if row is None: + session.add(PullRequest( + forge = forge, + repository = info.repository, + number = info.number, + review_id = uuid.uuid4(), + state = info.state.value, + title = info.title, + author = info.author, + url = info.url, + base_ref = info.base_ref, + base_sha = info.base_sha, + head_sha = info.head_sha, + fetch_ref = info.fetch_ref, + forge_updated_at = info.updated_at, + head_changed_at = now, + settle_deadline = settle_deadline, + generation = 1, + processed_generation = 0, + error_count = 0, + )) + return True + + material = ( + row.state != info.state.value or + row.head_sha != info.head_sha or + row.base_ref != info.base_ref or + row.fetch_ref != info.fetch_ref + ) + if row.head_sha != info.head_sha: + row.head_changed_at = now + row.settle_deadline = settle_deadline + row.state = info.state.value + row.title = info.title + row.author = info.author + row.url = info.url + row.base_ref = info.base_ref + row.base_sha = info.base_sha + row.head_sha = info.head_sha + row.fetch_ref = info.fetch_ref + row.forge_updated_at = info.updated_at + if material: + _bump_generation(row) + return material + + async def upsert_ci_run(self, + pull_request_id: int, + run: CIRunInfo) -> bool: + '''Record the latest CI run attempt; return whether it changed.''' + artifacts = _artifacts_to_json(run.artifacts) + conclusion = run.conclusion.value if run.conclusion is not None else None + async with self._session() as session, session.begin(): + row = await session.get(CIRun, pull_request_id) + if row is not None and ( + row.run_id == run.run_id and + row.attempt == run.attempt and + row.head_sha == run.head_sha and + row.status == run.status.value and + row.conclusion == conclusion and + row.artifacts == artifacts): + return False + + if row is None: + row = CIRun(pull_request_id = pull_request_id) + session.add(row) + row.run_id = run.run_id + row.attempt = run.attempt + row.head_sha = run.head_sha + row.status = run.status.value + row.conclusion = conclusion + row.completed_at = run.completed_at + row.url = run.url + row.artifacts = artifacts + + pull_request = await session.get(PullRequest, pull_request_id) + assert pull_request is not None + _bump_generation(pull_request) + return True + + async def get_pull_request(self, + pull_request_id: int + ) -> Optional[PullRequestRecord]: + async with self._session() as session: + row = await session.get(PullRequest, pull_request_id) + return _pull_request_record(row) if row is not None else None + + async def find_pull_request(self, + forge: str, + repository: str, + number: int) -> Optional[PullRequestRecord]: + async with self._session() as session: + result = await session.execute( + select(PullRequest) + .where(PullRequest.forge == forge) + .where(PullRequest.repository == repository) + .where(PullRequest.number == number) + ) + row = result.scalar_one_or_none() + return _pull_request_record(row) if row is not None else None + + async def open_pull_requests(self, + forge: str, + repository: Optional[str] = None + ) -> list[PullRequestRecord]: + statement = ( + select(PullRequest) + .where(PullRequest.forge == forge) + .where(PullRequest.state == PullRequestState.OPEN.value) + .order_by(PullRequest.id) + ) + if repository is not None: + statement = statement.where(PullRequest.repository == repository) + async with self._session() as session: + result = await session.execute(statement) + return [_pull_request_record(row) for row in result.scalars()] + + async def open_pull_requests_with_head(self, + forge: str, + repository: str, + head_sha: str + ) -> list[PullRequestRecord]: + async with self._session() as session: + result = await session.execute( + select(PullRequest) + .where(PullRequest.forge == forge) + .where(PullRequest.repository == repository) + .where(PullRequest.head_sha == head_sha) + .where(PullRequest.state == PullRequestState.OPEN.value) + ) + return [_pull_request_record(row) for row in result.scalars()] + + async def open_pull_request_count(self, forge: str, repository: str) -> int: + async with self._session() as session: + result = await session.execute( + select(func.count()) + .select_from(PullRequest) + .where(PullRequest.forge == forge) + .where(PullRequest.repository == repository) + .where(PullRequest.state == PullRequestState.OPEN.value) + ) + return int(result.scalar_one()) + + async def due_pull_requests(self, + forge: str, + now: datetime) -> list[PullRequestRecord]: + '''Pull requests needing processing whose retry delay (if any) is over.''' + async with self._session() as session: + result = await session.execute( + select(PullRequest) + .where(PullRequest.forge == forge) + .where(PullRequest.generation > PullRequest.processed_generation) + .where( + PullRequest.next_attempt_at.is_(None) | + (PullRequest.next_attempt_at <= now) + ) + .order_by(PullRequest.id) + ) + return [_pull_request_record(row) for row in result.scalars()] + + async def ci_run(self, pull_request_id: int) -> Optional[CIRunInfo]: + async with self._session() as session: + row = await session.get(CIRun, pull_request_id) + return _ci_run_info(row) if row is not None else None + + async def mark_dirty(self, pull_request_id: int) -> None: + async with self._session() as session, session.begin(): + row = await session.get(PullRequest, pull_request_id) + if row is not None: + _bump_generation(row) + + async def mark_processed(self, + pull_request_id: int, + generation: int) -> None: + '''Record that processing as of ``generation`` succeeded. + + If ingestion bumped the generation meanwhile, the pull request stays + dirty and is processed again. + ''' + async with self._session() as session, session.begin(): + row = await session.get(PullRequest, pull_request_id) + if row is None: + return + row.processed_generation = max(row.processed_generation, generation) + row.error_count = 0 + row.next_attempt_at = None + row.last_error = None + + async def mark_failed(self, + pull_request_id: int, + generation: int, + error: str, + *, + now: datetime, + max_attempts: int, + base_delay: float, + max_delay: float) -> bool: + '''Record a processing failure and schedule a retry with exponential + backoff. After ``max_attempts`` the service gives up until something + about the pull request changes. Returns whether it gave up. + ''' + async with self._session() as session, session.begin(): + row = await session.get(PullRequest, pull_request_id) + if row is None: + return True + row.last_error = error + if row.generation != generation: + # Changed while processing; retry right away with the new state. + return False + row.error_count += 1 + if row.error_count >= max_attempts: + row.processed_generation = generation + row.next_attempt_at = None + return True + delay = min(max_delay, base_delay * 2 ** (row.error_count - 1)) + row.next_attempt_at = now + timedelta(seconds = delay) + return False + + async def sections(self, pull_request_id: int) -> list[SectionRecord]: + async with self._session() as session: + result = await session.execute( + select(Section) + .where(Section.pull_request_id == pull_request_id) + .order_by(Section.slug) + ) + return [_section_record(row) for row in result.scalars()] + + async def replace_sections(self, + pull_request_id: int, + sections: Sequence[SectionRecord]) -> None: + async with self._session() as session, session.begin(): + await session.execute( + delete(Section).where(Section.pull_request_id == pull_request_id) + ) + for section in sections: + session.add(Section( + pull_request_id = pull_request_id, + kind = section.kind, + name = section.name, + slug = section.slug, + source_key = section.source_key, + content_fingerprint = section.content_fingerprint, + root = str(section.root), + home = str(section.home), + details = dict(section.details), + )) + + async def announcement(self, + pull_request_id: int) -> Optional[AnnouncementRecord]: + async with self._session() as session: + row = await session.get(Announcement, pull_request_id) + if row is None: + return None + return AnnouncementRecord( + fingerprint = row.fingerprint, + sections = dict(row.sections), + pending_fingerprint = row.pending_fingerprint, + last_comment_id = row.last_comment_id, + last_posted_at = row.last_posted_at, + ) + + async def has_announcements(self, forge: str) -> bool: + async with self._session() as session: + result = await session.execute( + select(func.count()) + .select_from(Announcement) + .join(PullRequest, PullRequest.id == Announcement.pull_request_id) + .where(PullRequest.forge == forge) + ) + return int(result.scalar_one()) > 0 + + async def set_pending_announcement(self, + pull_request_id: int, + fingerprint: str) -> None: + async with self._session() as session, session.begin(): + row = await session.get(Announcement, pull_request_id) + if row is None: + row = Announcement(pull_request_id = pull_request_id, sections = {}) + session.add(row) + row.pending_fingerprint = fingerprint + + async def record_announcement(self, + pull_request_id: int, + fingerprint: str, + sections: dict[str, str], + *, + comment_id: Optional[str], + posted_at: Optional[datetime]) -> None: + '''Record ``fingerprint`` as announced. With no ``comment_id`` the state + is adopted silently (e.g. on a fresh database). + ''' + async with self._session() as session, session.begin(): + row = await session.get(Announcement, pull_request_id) + if row is None: + row = Announcement(pull_request_id = pull_request_id) + session.add(row) + row.fingerprint = fingerprint + row.sections = dict(sections) + row.pending_fingerprint = None + if comment_id is not None: + row.last_comment_id = comment_id + row.last_posted_at = posted_at diff --git a/dev_tools/pylibs/edgacknowledg/forge/http.py b/dev_tools/pylibs/edgacknowledg/forge/http.py new file mode 100644 index 0000000000..396aac2c6a --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/http.py @@ -0,0 +1,185 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''HTTP plumbing shared by forge REST clients. + +Retries transient failures, waits out rate limits (``Retry-After`` or +``x-ratelimit-*`` / ``ratelimit-*`` reset headers), follows ``Link`` +pagination, and downloads files without sending credentials to the storage +hosts downloads may redirect to. +''' + +import asyncio +import logging +import time + +from pathlib import Path +from typing import Any, Mapping, Optional + +import httpx + +from edgacknowledg.forge import ArtifactTooLargeError, ForgeError + +_log = logging.getLogger(__name__) + +PAGE_SIZE = 100 +MAX_RATE_LIMIT_WAIT = 15 * 60.0 +MAX_ATTEMPTS = 4 + +_REDIRECTS = (301, 302, 303, 307, 308) + + +def _rate_limit_wait(response: httpx.Response) -> Optional[float]: + if response.status_code not in (403, 429): + return None + retry_after = response.headers.get('retry-after') + if retry_after is not None and retry_after.isdigit(): + return min(float(retry_after), MAX_RATE_LIMIT_WAIT) + for prefix in ('x-ratelimit-', 'ratelimit-'): + if response.headers.get(f"{prefix}remaining") == '0': + reset = response.headers.get(f"{prefix}reset", '') + if reset.isdigit(): + return min(max(1.0, float(reset) - time.time()), MAX_RATE_LIMIT_WAIT) + return None + + +class ForgeHttpClient: + '''An authenticated REST client for one forge API.''' + + def __init__(self, + *, + base_url: str, + headers: Mapping[str, str], + transport: Optional[httpx.AsyncBaseTransport] = None) -> None: + self.base_url = base_url.rstrip('/') + self._client = httpx.AsyncClient( + base_url = self.base_url, + headers = {'User-Agent': 'acknowledg-forge', **headers}, + timeout = httpx.Timeout(30.0, read = 120.0), + transport = transport, + follow_redirects = False + ) + # Redirect targets (object storage) must not see the credentials. + self._anonymous = httpx.AsyncClient( + headers = {'User-Agent': 'acknowledg-forge'}, + timeout = httpx.Timeout(30.0, read = 300.0), + transport = transport, + follow_redirects = True + ) + + async def aclose(self) -> None: + await self._client.aclose() + await self._anonymous.aclose() + + async def request(self, + method: str, + path: str, + *, + params: Optional[Mapping[str, Any]] = None, + json_body: Optional[Any] = None) -> httpx.Response: + '''Send a request, retrying transient failures and waiting out rate + limits; raise ``ForgeError`` on failure. + ''' + for attempt in range(MAX_ATTEMPTS): + last_attempt = attempt + 1 == MAX_ATTEMPTS + try: + response = await self._client.request( + method, + path, + params = params, + json = json_body + ) + except httpx.HTTPError as ex: + if last_attempt: + raise ForgeError(f"{method} {path} failed: {ex}") from ex + await asyncio.sleep(2 ** attempt) + continue + + wait = _rate_limit_wait(response) + if wait is not None and not last_attempt: + _log.warning('rate limited; waiting %.0f s', wait) + await asyncio.sleep(wait) + continue + if response.status_code >= 500 and not last_attempt: + await asyncio.sleep(2 ** attempt) + continue + if response.is_error or response.status_code in _REDIRECTS: + raise ForgeError( + f"{method} {path} failed: {response.status_code} " + f"{response.text[:500]}" + ) + return response + raise ForgeError(f"{method} {path} failed after {MAX_ATTEMPTS} attempts") + + async def get_json(self, + path: str, + params: Optional[Mapping[str, Any]] = None) -> Any: + return (await self.request('GET', path, params = params)).json() + + async def paginate(self, + path: str, + params: Optional[Mapping[str, Any]] = None, + *, + key: Optional[str] = None) -> list[Any]: + '''Collect every page of a list endpoint (``key`` selects the list in + wrapped responses such as ``{"workflow_runs": [...]}``). + ''' + items: list[Any] = [] + next_path: Optional[str] = path + next_params: Optional[Mapping[str, Any]] = { + **(params or {}), + 'per_page': PAGE_SIZE, + } + while next_path is not None: + response = await self.request('GET', next_path, params = next_params) + payload = response.json() + items += payload[key] if key is not None else payload + # The next link is absolute and carries its own query. + next_path = response.links.get('next', {}).get('url') + next_params = None + return items + + async def download(self, + path: str, + destination: Path, + *, + max_bytes: int) -> None: + '''Stream ``path`` (served directly or via a redirect to storage) to + ``destination``, failing past ``max_bytes``. Not retried here; callers + retry the whole operation. + ''' + try: + async with self._client.stream('GET', path) as response: + if response.status_code not in _REDIRECTS: + if response.is_error: + raise ForgeError( + f"download of {path} failed: {response.status_code}" + ) + await _write_capped(response, destination, max_bytes, path) + return + location = response.headers.get('location') + if not location: + raise ForgeError(f"redirect without a location for {path}") + async with self._anonymous.stream('GET', location) as stream: + if stream.is_error: + raise ForgeError(f"download of {path} failed: {stream.status_code}") + await _write_capped(stream, destination, max_bytes, path) + except httpx.HTTPError as ex: + raise ForgeError(f"download of {path} failed: {ex}") from ex + + +async def _write_capped(stream: httpx.Response, + destination: Path, + max_bytes: int, + label: str) -> None: + written = 0 + with open(destination, 'wb') as handle: + async for chunk in stream.aiter_bytes(): + written += len(chunk) + if written > max_bytes: + raise ArtifactTooLargeError( + f"{label} is larger than {max_bytes} bytes" + ) + handle.write(chunk) diff --git a/dev_tools/pylibs/edgacknowledg/forge/ingest.py b/dev_tools/pylibs/edgacknowledg/forge/ingest.py new file mode 100644 index 0000000000..a61f85d077 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/ingest.py @@ -0,0 +1,126 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Ingestion: the one path from forge state into the database. + +Polling and webhooks both end up here, so however the service learns about +a pull request, the result is the same: the database is updated and, if +anything material changed, the processor is woken. +''' + +import asyncio +import logging + +from datetime import datetime, timezone +from typing import Callable + +from edgacknowledg.forge import ( + Forge, + PullRequestInfo, + PullRequestState, +) +from edgacknowledg.forge.config import ForgeServiceConfig +from edgacknowledg.forge.db import ForgeStore, PullRequestRecord + +_log = logging.getLogger(__name__) + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class Ingestor: + '''Upserts forge state and wakes the processor on material changes.''' + + def __init__(self, + store: ForgeStore, + forge: Forge, + config: ForgeServiceConfig, + *, + wake: asyncio.Event, + clock: Callable[[], datetime] = utc_now) -> None: + self._store = store + self._forge = forge + self._config = config + self._wake = wake + self._clock = clock + + async def ingest_pull_request(self, info: PullRequestInfo) -> None: + changed = await self._store.upsert_pull_request( + self._forge.name, + info, + now = self._clock(), + settle_timeout = self._config.settle_timeout + ) + if changed: + _log.info( + 'pull request %s#%d changed (%s, head %s)', + info.repository, info.number, info.state.value, info.head_sha[:12] + ) + self._wake.set() + if info.state == PullRequestState.OPEN: + record = await self._store.find_pull_request( + self._forge.name, + info.repository, + info.number + ) + assert record is not None + await self._refresh_ci(record) + + async def _refresh_ci(self, record: PullRequestRecord) -> None: + if not self._config.ci_tracked: + return + run = await self._forge.latest_ci_run(record.info) + if run is None: + return + if await self._store.upsert_ci_run(record.id, run): + _log.info( + 'CI for %s#%d changed (run %s attempt %d: %s)', + record.info.repository, record.info.number, + run.run_id, run.attempt, run.status.value + ) + self._wake.set() + + def _is_watched(self, repository: str) -> bool: + if repository in self._config.repositories: + return True + # E.g. an organization-wide webhook delivering for other repositories. + _log.debug('ignoring %s, which is not a configured repository', + repository) + return False + + async def refresh_pull_request(self, repository: str, number: int) -> None: + '''Re-query one pull request (and its CI) from the forge.''' + if not self._is_watched(repository): + return + info = await self._forge.get_pull_request(repository, number) + await self.ingest_pull_request(info) + + async def refresh_ci_for_head(self, repository: str, head_sha: str) -> None: + '''Re-query CI for the open pull requests whose head is ``head_sha``.''' + if not self._is_watched(repository): + return + for record in await self._store.open_pull_requests_with_head( + self._forge.name, + repository, + head_sha): + await self._refresh_ci(record) + + async def sync_repository(self, repository: str) -> None: + '''Bring every pull request in ``repository`` up to date. + + Open pull requests come from one listing; pull requests the database + still thinks are open but the listing lacks were closed or merged in + the meantime, so they're queried individually. + ''' + open_infos = await self._forge.list_open_pull_requests(repository) + open_numbers = {info.number for info in open_infos} + for info in open_infos: + await self.ingest_pull_request(info) + for record in await self._store.open_pull_requests( + self._forge.name, + repository): + if record.info.number not in open_numbers: + await self.refresh_pull_request(repository, record.info.number) diff --git a/dev_tools/pylibs/edgacknowledg/forge/poller.py b/dev_tools/pylibs/edgacknowledg/forge/poller.py new file mode 100644 index 0000000000..894fced7c9 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/poller.py @@ -0,0 +1,40 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Polling: catch up at startup, then (optionally) keep checking.''' + +import asyncio +import logging + +from edgacknowledg.forge.config import ForgeServiceConfig +from edgacknowledg.forge.ingest import Ingestor + +_log = logging.getLogger(__name__) + + +async def sync_all(ingestor: Ingestor, config: ForgeServiceConfig) -> None: + '''Sync every configured repository once; failures are logged per + repository so one bad repository doesn't stall the others. + ''' + for repository in config.repositories: + try: + await ingestor.sync_repository(repository) + except asyncio.CancelledError: + raise + except Exception: + _log.exception('failed to sync %s', repository) + + +async def run_poller(ingestor: Ingestor, config: ForgeServiceConfig) -> None: + '''Sync at startup, which picks up anything missed while the service was + down, then every ``poll_interval`` seconds (never, if it's 0). + ''' + while True: + _log.info('syncing %d repositories', len(config.repositories)) + await sync_all(ingestor, config) + if config.poll_interval <= 0: + _log.info('polling disabled after the startup sync') + return + await asyncio.sleep(config.poll_interval) diff --git a/dev_tools/pylibs/edgacknowledg/forge/processor.py b/dev_tools/pylibs/edgacknowledg/forge/processor.py new file mode 100644 index 0000000000..c4d40f8bb6 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/processor.py @@ -0,0 +1,660 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Processing: reconcile the database with workspaces and published reviews. + +For every pull request that changed (see ``ForgeStore`` generations), work +out which sections it should have, build any that are missing or stale, and +publish exactly those. Closed pull requests (and ones no longer targeting a +served base branch) have their review and workspace removed. +''' + +import asyncio +import hashlib +import json +import logging +import secrets + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path, PurePosixPath +from typing import Awaitable, Callable, Optional + +import edgtest +import edgutil + +from edgacknowledg import section_slug +from edgacknowledg.forge import ( + ArtifactInfo, + CIConclusion, + CIRunInfo, + CIStatus, + Forge, + PullRequestState, +) +from edgacknowledg.forge.config import ForgeServiceConfig +from edgacknowledg.forge.db import ForgeStore, PullRequestRecord, SectionRecord +from edgacknowledg.forge.ingest import utc_now +from edgacknowledg.forge.publisher import ( + KIND_BENCH, + KIND_TEST_CHANGES, + KIND_TEST_FAILURES, + Publisher, + PublisherUnavailableError, + ReviewIdentity, +) +from edgacknowledg.forge.sandbox import Sandbox +from edgacknowledg.forge.workspace import ( + BENCH_BASELINE_PATTERNS, + Workspaces, + changes_patterns, + ensure_real_directory, + extract_artifact, + failures_patterns, + remove_tree, +) + +_log = logging.getLogger(__name__) + +CHANGES_NAME = 'Test - Changes' +FAILURES_NAME = 'Test - Failures' + +# (artifact subdirectory, checked-in baseline tag, section name) +BENCH_SECTIONS = ( + ('c', 'release-c', 'Bench - C'), + ('cpp', 'release-cpp', 'Bench - C++'), +) + +CI_METADATA_FILE = 'ci-meta.json' + +# Retries for a failing pull request: 1 min, 2 min, ... up to 1 h; give up +# after this many attempts until the pull request changes again. +MAX_ATTEMPTS = 8 +RETRY_BASE_DELAY = 60.0 +RETRY_MAX_DELAY = 60.0 * 60 + +SWEEP_INTERVAL = 60.0 +MAINTENANCE_INTERVAL = 6 * 60.0 * 60 + + +class ProcessingError(Exception): + '''Raised when a pull request can't be processed (it will be retried).''' + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with open(path, 'rb') as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def _reusable(section: Optional[SectionRecord], + source_key: str) -> Optional[SectionRecord]: + '''Return ``section`` if it was built from ``source_key`` and its files + are still there. + ''' + if section is None or section.source_key != source_key: + return None + if not section.root.is_dir(): + return None + return section + + +def _changes_test_ids(changes_elist: Path) -> list[str]: + with open(changes_elist, encoding = 'utf-8', errors = 'replace') as handle: + return [line.strip() for line in handle if line.strip()] + + +@dataclass +class _StagedArtifact: + '''A downloaded and extracted CI artifact, before it moves into place.''' + directory: Path + fingerprint: str + tested_sha: Optional[str] + + +class Processor: + '''Reconciles pull requests and announces changes (if enabled).''' + + def __init__(self, + *, + store: ForgeStore, + forge: Forge, + config: ForgeServiceConfig, + workspaces: Workspaces, + sandbox: Sandbox, + publisher: Publisher, + wake: asyncio.Event, + after_sweep: Optional[Callable[[], Awaitable[None]]] = None, + clock: Callable[[], datetime] = utc_now) -> None: + self._store = store + self._forge = forge + self._config = config + self._workspaces = workspaces + self._sandbox = sandbox + self._publisher = publisher + self._wake = wake + self._after_sweep = after_sweep + self._clock = clock + self._in_flight: dict[int, asyncio.Task[None]] = {} + self._semaphore = asyncio.Semaphore(config.max_concurrency) + + @property + def busy_pull_requests(self) -> frozenset[int]: + return frozenset(self._in_flight) + + # Loop + + async def run(self) -> None: + '''Restore what was being served, then process changes forever.''' + await self._publisher.wait_connected() + await self.restore() + last_maintenance = self._clock() + while True: + await self._publisher.wait_connected() + self._wake.clear() + await self.start_due() + if self._after_sweep is not None: + await self._after_sweep() + if ((self._clock() - last_maintenance).total_seconds() >= + MAINTENANCE_INTERVAL): + await self.maintain() + last_maintenance = self._clock() + try: + await asyncio.wait_for(self._wake.wait(), SWEEP_INTERVAL) + except asyncio.TimeoutError: + pass + + async def start_due(self) -> None: + '''Start processing every due pull request not already in progress.''' + for record in await self._store.due_pull_requests( + self._forge.name, + self._clock()): + if record.id in self._in_flight: + continue + task = asyncio.create_task(self._process_guarded(record.id)) + self._in_flight[record.id] = task + + async def drain(self) -> None: + '''Wait until no pull request is being processed (for tests).''' + while self._in_flight: + await asyncio.gather(*self._in_flight.values(), return_exceptions = True) + + async def _process_guarded(self, pull_request_id: int) -> None: + try: + async with self._semaphore: + record = await self._store.get_pull_request(pull_request_id) + if record is None or not record.is_dirty: + return + generation = record.generation + label = f"{record.info.repository}#{record.info.number}" + try: + await self.process(record) + except PublisherUnavailableError as ex: + # Not the pull request's fault; it stays dirty and is retried + # once the connection is back. + _log.warning('deferring %s: %s', label, ex) + return + except asyncio.CancelledError: + raise + except Exception as ex: + _log.exception('processing %s failed', label) + gave_up = await self._store.mark_failed( + pull_request_id, + generation, + f"{type(ex).__name__}: {ex}", + now = self._clock(), + max_attempts = MAX_ATTEMPTS, + base_delay = RETRY_BASE_DELAY, + max_delay = RETRY_MAX_DELAY + ) + if gave_up: + _log.error('giving up on %s until it changes again', label) + return + await self._store.mark_processed(pull_request_id, generation) + # It may have changed while it was processed. + self._wake.set() + finally: + self._in_flight.pop(pull_request_id, None) + + async def restore(self) -> None: + '''Re-serve what was served before a restart, without rebuilding. + + Pull requests whose files are gone are marked to be rebuilt instead; + since section fingerprints are content-derived, rebuilding yields the + same state (and so no comment). + ''' + for record in await self._store.open_pull_requests(self._forge.name): + sections = await self._store.sections(record.id) + if len(sections) == 0: + continue + if all(section.root.is_dir() for section in sections): + await self._publisher.sync(self._identity(record), sections) + else: + await self._store.mark_dirty(record.id) + _log.info('restored previously served reviews') + + async def maintain(self) -> None: + '''Periodic git housekeeping for every repository.''' + for repository in self._config.repositories: + try: + await self._workspaces.collect_garbage( + repository, + await self._store.open_pull_request_count( + self._forge.name, + repository + ) + ) + except Exception: + _log.exception('maintenance of %s failed', repository) + + # Processing + + def _identity(self, record: PullRequestRecord) -> ReviewIdentity: + info = record.info + return ReviewIdentity( + review_id = record.review_id, + name = f"{info.repository}#{info.number}: {info.title}", + timestamp = record.head_changed_at.isoformat() + ) + + async def process(self, record: PullRequestRecord) -> None: + info = record.info + if (info.state != PullRequestState.OPEN or + info.base_ref not in self._config.base_branches): + await self._tear_down(record) + return + + current = { + section.slug: section + for section in await self._store.sections(record.id) + } + head_sha, base_tip = await self._workspaces.fetch_pull_request(info) + + desired: list[SectionRecord] = [] + changes = await self._changes_section( + record, + head_sha, + base_tip, + current.get(section_slug(CHANGES_NAME)) + ) + if changes is not None: + desired.append(changes) + desired += await self._ci_sections(record, head_sha, current) + + await self._publisher.sync(self._identity(record), desired) + await self._store.replace_sections(record.id, desired) + + kept_run_dirs = {section.details.get('run_dir') for section in desired} + for section in current.values(): + run_dir = section.details.get('run_dir') + if run_dir is not None and run_dir not in kept_run_dirs: + remove_tree(Path(run_dir)) + + async def _tear_down(self, record: PullRequestRecord) -> None: + '''Stop serving a pull request and free its disk space; no comment.''' + info = record.info + await self._publisher.remove(record.review_id) + await self._workspaces.remove_review(info, record.review_id) + await self._store.replace_sections(record.id, []) + await self._workspaces.collect_garbage( + info.repository, + await self._store.open_pull_request_count( + self._forge.name, + info.repository + ) + ) + _log.info('stopped serving %s#%d (%s)', + info.repository, info.number, info.state.value) + + def _new_staging_dir(self, record: PullRequestRecord, label: str) -> Path: + staging_root = ensure_real_directory( + self._workspaces.reviews_dir.parent, + PurePosixPath( + self._workspaces.reviews_dir.name, + str(record.review_id), + 'staging' + ) + ) + staging = staging_root / f"{label}-{secrets.token_hex(4)}" + staging.mkdir() + return staging + + async def _changes_section(self, + record: PullRequestRecord, + head_sha: str, + base_tip: str, + current: Optional[SectionRecord] + ) -> Optional[SectionRecord]: + '''Test - Changes: the pull request's expectation diffs, rematerialized + on the merge-base by the frozen tooling (nothing from the pull request + is executed; the diff is only data for 'patch'). + ''' + info = record.info + merge_base = await self._workspaces.merge_base( + info.repository, + base_tip, + head_sha + ) + source_key = f"{merge_base}..{head_sha}" + reused = _reusable(current, source_key) + if reused is not None: + return reused + + diff = await self._workspaces.diff( + info.repository, + merge_base, + head_sha, + ['tests/'] + ) + if diff.strip() == '': + return None + + worktree = await self._workspaces.checkout( + info, + record.review_id, + 'base', + merge_base, + changes_patterns(diff) + ) + runs_dir = ensure_real_directory(worktree, PurePosixPath('tests', 'runs')) + run_dir = runs_dir / f"remat-{_sha256(source_key.encode())[:16]}" + remove_tree(run_dir) + + staging = runs_dir / f".tmp-{run_dir.name}-{secrets.token_hex(4)}" + staging.mkdir() + try: + diff_file = staging / 'pull-request.diff' + diff_file.write_text(diff, encoding = 'utf-8') + timestamp = self._clock().strftime(edgutil.RUN_TIMESTAMP_FORMAT) + result = await self._sandbox.run( + [ + 'python3', + str(self._config.tools_dir / 'bin' / 'edg-rematerialize-test-run'), + '--runs-dir', str(staging), + '--run-timestamp', timestamp, + str(diff_file), + ], + read_only = [worktree], + writable = [staging], + env = {'EDG_TEST_HOME': str(worktree / 'tests')}, + cwd = worktree, + timeout = self._config.rematerialize_timeout + ) + if not result.succeeded: + reason = 'timed out' if result.timed_out else ( + f"exited with {result.returncode}" + ) + raise ProcessingError( + f"edg-rematerialize-test-run {reason}: " + f"{result.output.strip()[-2000:]}" + ) + if len(edgtest.find_changes_files(staging / timestamp)) == 0: + _log.warning( + '%s#%d: edg-rematerialize-test-run produced no changes.elist; ' + 'not serving %s', info.repository, info.number, CHANGES_NAME + ) + remove_tree(staging) + return None + staging.rename(run_dir) + except BaseException: + remove_tree(staging) + raise + + return SectionRecord( + kind = KIND_TEST_CHANGES, + name = CHANGES_NAME, + slug = section_slug(CHANGES_NAME), + source_key = source_key, + content_fingerprint = _sha256(diff.encode()), + root = run_dir / timestamp, + home = worktree / 'tests', + details = {'run_dir': str(run_dir)}, + ) + + async def _stage_artifact(self, + record: PullRequestRecord, + artifact: ArtifactInfo, + label: str) -> _StagedArtifact: + staging = self._new_staging_dir(record, label) + try: + zip_path = staging.with_name(f"{staging.name}.zip") + try: + await self._forge.download_artifact( + record.info.repository, + artifact, + zip_path, + max_bytes = self._config.max_artifact_bytes + ) + fingerprint = artifact.digest or f"sha256:{_file_sha256(zip_path)}" + await asyncio.to_thread( + extract_artifact, + zip_path, + staging, + max_bytes = self._config.max_extracted_bytes + ) + finally: + zip_path.unlink(missing_ok = True) + + tested_sha = None + metadata_path = staging / CI_METADATA_FILE + if metadata_path.is_file(): + metadata = json.loads(metadata_path.read_text(encoding = 'utf-8')) + value = metadata.get('tested_sha') + if isinstance(value, str) and len(value) in (40, 64): + tested_sha = value + return _StagedArtifact(staging, fingerprint, tested_sha) + except BaseException: + remove_tree(staging) + raise + + async def _ci_sections(self, + record: PullRequestRecord, + head_sha: str, + current: dict[str, SectionRecord] + ) -> list[SectionRecord]: + '''Test - Failures and Bench sections, from the latest completed CI run + for the head commit. + ''' + ci_slugs = [section_slug(FAILURES_NAME)] + [ + section_slug(name) for _, _, name in BENCH_SECTIONS + ] + previous = [ + current[slug] for slug in ci_slugs + if slug in current and current[slug].root.is_dir() + ] + if not self._config.ci_tracked: + return [] + run = await self._store.ci_run(record.id) + if (run is None or run.head_sha != head_sha or + run.status != CIStatus.COMPLETED): + # Keep showing the previous results until the new run finishes. + return previous + + run_key = f"{run.run_id}.{run.attempt}" + failures_artifact = None + if run.conclusion != CIConclusion.SUCCESS: + failures_artifact = run.artifact(self._config.test_artifact) + bench_artifact = run.artifact(self._config.bench_artifact) + + # Expired artifacts can't be downloaded again; keep what was built. + failures = current.get(section_slug(FAILURES_NAME)) + if failures_artifact is not None and failures_artifact.expired: + kept_failures: Optional[SectionRecord] = failures + failures_artifact = None + else: + kept_failures = _reusable( + failures, + f"{run_key}:{failures_artifact.id}" if failures_artifact else '' + ) + current_benches = [ + current[section_slug(name)] + for _, _, name in BENCH_SECTIONS + if section_slug(name) in current + ] + bench_key = f"{run_key}:{bench_artifact.id}" if bench_artifact else '' + if bench_artifact is not None and bench_artifact.expired: + kept_benches = current_benches + bench_artifact = None + else: + kept_benches = [ + section for section in current_benches + if _reusable(section, bench_key) is not None + ] + + build_failures = failures_artifact is not None and kept_failures is None + build_benches = bench_artifact is not None and len(kept_benches) == 0 + if not build_failures and not build_benches: + result = [kept_failures] if kept_failures is not None else [] + return result + kept_benches + + return await self._build_ci_sections( + record, + head_sha, + run, + failures_artifact if build_failures else None, + bench_artifact if build_benches else None, + kept_failures, + kept_benches + ) + + async def _build_ci_sections(self, + record: PullRequestRecord, + head_sha: str, + run: CIRunInfo, + failures_artifact: Optional[ArtifactInfo], + bench_artifact: Optional[ArtifactInfo], + kept_failures: Optional[SectionRecord], + kept_benches: list[SectionRecord] + ) -> list[SectionRecord]: + info = record.info + run_key = f"{run.run_id}.{run.attempt}" + staged_failures = None + staged_bench = None + try: + if failures_artifact is not None: + staged_failures = await self._stage_artifact( + record, + failures_artifact, + 'failures' + ) + if bench_artifact is not None: + staged_bench = await self._stage_artifact( + record, + bench_artifact, + 'bench' + ) + + # The tests CI flagged, from the artifact's changes.elist files. + failed_tests: list[str] = [] + changes_files = {} + if staged_failures is not None: + changes_files = edgtest.find_changes_files(staged_failures.directory) + for changes_file in changes_files.values(): + failed_tests += _changes_test_ids(changes_file) + if kept_failures is not None: + failed_tests += kept_failures.details.get('tests', []) + + # Check out what CI tested (for pull requests, usually a merge + # commit), falling back to the head if the forge no longer has it. + tested_sha = head_sha + if kept_failures is not None: + tested_sha = kept_failures.details.get('tested_sha', head_sha) + for staged in (staged_failures, staged_bench): + if staged is not None and staged.tested_sha is not None: + if await self._workspaces.fetch_commit(info, staged.tested_sha): + tested_sha = staged.tested_sha + else: + _log.warning('%s#%d: CI-tested commit %s is unavailable; using ' + 'the head', info.repository, info.number, + staged.tested_sha[:12]) + break + worktree = await self._workspaces.checkout( + info, + record.review_id, + 'head', + tested_sha, + failures_patterns(failed_tests) + ) + + sections: list[SectionRecord] = [] + if kept_failures is not None: + sections.append(kept_failures) + elif staged_failures is not None and len(changes_files) > 0: + assert failures_artifact is not None + runs_dir = ensure_real_directory( + worktree, + PurePosixPath('tests', 'runs') + ) + run_dir = runs_dir / f"ci-{run.run_id}-{run.attempt}" + remove_tree(run_dir) + staged_failures.directory.rename(run_dir) + sections.append(SectionRecord( + kind = KIND_TEST_FAILURES, + name = FAILURES_NAME, + slug = section_slug(FAILURES_NAME), + source_key = f"{run_key}:{failures_artifact.id}", + content_fingerprint = staged_failures.fingerprint, + root = run_dir, + home = worktree / 'tests', + details = { + 'run_dir': str(run_dir), + 'tests': sorted(set(failed_tests)), + 'tested_sha': tested_sha, + }, + )) + + sections += kept_benches + if staged_bench is not None: + assert bench_artifact is not None + sections += self._bench_sections( + worktree, + run, + bench_artifact, + staged_bench + ) + return sections + finally: + for staged in (staged_failures, staged_bench): + if staged is not None: + remove_tree(staged.directory) + + def _bench_sections(self, + worktree: Path, + run: CIRunInfo, + artifact: ArtifactInfo, + staged: _StagedArtifact) -> list[SectionRecord]: + bench_home = ensure_real_directory(worktree, PurePosixPath('benchmarks')) + runs_dir = ensure_real_directory(bench_home, PurePosixPath('runs')) + run_dir = runs_dir / f"ci-{run.run_id}-{run.attempt}" + remove_tree(run_dir) + staged.directory.rename(run_dir) + + sections = [] + for subdirectory, tag, name in BENCH_SECTIONS: + root = run_dir / subdirectory + baseline_dir = bench_home / 'baselines' / 'project' / tag + if not root.is_dir() or not baseline_dir.is_dir(): + continue + sections.append(SectionRecord( + kind = KIND_BENCH, + name = name, + slug = section_slug(name), + source_key = f"{run.run_id}.{run.attempt}:{artifact.id}", + content_fingerprint = f"{staged.fingerprint}/{subdirectory}", + root = root, + home = bench_home, + details = { + 'run_dir': str(run_dir), + 'baseline_dir': str(baseline_dir), + 'tag': tag, + }, + )) + return sections diff --git a/dev_tools/pylibs/edgacknowledg/forge/publisher.py b/dev_tools/pylibs/edgacknowledg/forge/publisher.py new file mode 100644 index 0000000000..87a4cccc0a --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/publisher.py @@ -0,0 +1,243 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Publishing review sections to AcknowlEDG over one batch connection.''' + +import asyncio +import logging +import uuid + +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Protocol, Sequence + +from edgacknowledg.client import ( + DEFAULT_OPEN_TIMEOUT_S, + BatchClientSession, + ClientError, + NotConnectedError, + ReviewClient, + form_section_http_url, +) +from edgacknowledg.client.bench import BenchReviewSession, BenchSourceHandler +from edgacknowledg.client.shared import ( + BackgroundWorkerPool, + ReviewClientHandler, +) +from edgacknowledg.client.test import TestSourceHandler +from edgacknowledg.forge.db import SectionRecord +from edgacknowledg.forge.sandbox import Sandbox + +_log = logging.getLogger(__name__) + +# Section kinds and the AcknowlEDG subscription type serving each. +KIND_TEST_CHANGES = 'test.changes' +KIND_TEST_FAILURES = 'test.failures' +KIND_BENCH = 'bench' + +_SUBSCRIPTION_TYPES = { + KIND_TEST_CHANGES: 'test.src', + KIND_TEST_FAILURES: 'test.src', + KIND_BENCH: 'bench.src', +} + +# Parallel cg_annotate runs across all benchmark sections. +_BENCH_WORKERS = 2 + + +class PublisherUnavailableError(Exception): + '''Raised when AcknowlEDG can't be reached right now (retry later).''' + + +@dataclass(frozen = True) +class ReviewIdentity: + review_id: uuid.UUID + name: str + timestamp: str + + +class Publisher(Protocol): + '''Where review sections are served (AcknowlEDG, or a test double).''' + + async def wait_connected(self) -> None: + ... + + async def sync(self, + review: ReviewIdentity, + sections: Sequence[SectionRecord]) -> None: + '''Serve exactly ``sections`` for ``review``.''' + ... + + async def remove(self, review_id: uuid.UUID) -> None: + ... + + def section_url(self, + review_id: uuid.UUID, + section: SectionRecord) -> Optional[str]: + ... + + +@dataclass +class _ServedSection: + record: SectionRecord + handler: ReviewClientHandler + + +class AcknowledgPublisher: + '''Serves sections with the stock AcknowlEDG handlers, read-only.''' + + def __init__(self, + *, + api_host: str, + api_port: int, + api_key: Optional[str], + ui_host: str, + ui_port: int, + sandbox: Sandbox) -> None: + self._session = BatchClientSession( + status_listener = self, + api_host = api_host, + api_port = api_port, + api_key = api_key, + open_timeout = DEFAULT_OPEN_TIMEOUT_S + ) + self._ui_host = ui_host + self._ui_port = ui_port + self._sandbox = sandbox + self._connected = asyncio.Event() + self._lock = asyncio.Lock() + self._reviews: dict[uuid.UUID, ReviewClient] = {} + self._served: dict[uuid.UUID, dict[str, _ServedSection]] = {} + self._bench_workers = BackgroundWorkerPool(_BENCH_WORKERS) + + # ConnectionStatusListener + def connected(self) -> None: + _log.info('connected to AcknowlEDG') + self._connected.set() + + def attempting_reconnect(self) -> None: + pass + + def disconnected(self) -> None: + _log.warning('lost the AcknowlEDG connection; reconnecting') + self._connected.clear() + + async def run(self) -> None: + '''Keep the batch connection up until cancelled.''' + self._bench_workers.ensure_started() + try: + await self._session.start() + await self._session.stopped() + finally: + await self._session.stop() + + async def wait_connected(self) -> None: + await self._connected.wait() + + def _require_connected(self) -> None: + if not self._connected.is_set(): + raise PublisherUnavailableError('not connected to AcknowlEDG') + + def section_url(self, + review_id: uuid.UUID, + section: SectionRecord) -> Optional[str]: + return form_section_http_url( + self._ui_host, + self._ui_port, + review_id, + _SUBSCRIPTION_TYPES[section.kind], + section.slug + ) + + def _build_handler(self, section: SectionRecord) -> ReviewClientHandler: + # Commands run on reviewers' behalf (ifc-dump, cg_annotate) only see the + # section's tree, read-only. + command_prefix = self._sandbox.prefix(read_only = [section.home]) + if section.kind in (KIND_TEST_CHANGES, KIND_TEST_FAILURES): + return TestSourceHandler( + section.name, + section.slug, + section.home, + section.root, + command_prefix = command_prefix + ) + if section.kind == KIND_BENCH: + session = BenchReviewSession( + bench_home = section.home, + run_dir = section.root, + baseline_dir = Path(section.details['baseline_dir']), + tag = section.details['tag'], + command_prefix = command_prefix + ) + return BenchSourceHandler( + section.name, + section.slug, + session, + self._bench_workers + ) + raise ValueError(f"unknown section kind: {section.kind}") + + async def sync(self, + review: ReviewIdentity, + sections: Sequence[SectionRecord]) -> None: + async with self._lock: + self._require_connected() + try: + await self._sync(review, sections) + except (NotConnectedError, ClientError) as ex: + raise PublisherUnavailableError(str(ex)) from ex + + async def _sync(self, + review: ReviewIdentity, + sections: Sequence[SectionRecord]) -> None: + if len(sections) == 0: + await self._remove(review.review_id) + return + + client_review = self._reviews.get(review.review_id) + if client_review is None: + client_review = await self._session.create_review( + review.review_id, + review.name, + review.timestamp + ) + self._reviews[review.review_id] = client_review + served = self._served.setdefault(review.review_id, {}) + registered = client_review.sections() + desired = {section.slug: section for section in sections} + + for slug, current in list(served.items()): + wanted = desired.get(slug) + still_registered = any(h is current.handler for h in registered) + if (wanted is not None and still_registered and + wanted.name == current.record.name and + wanted.source_key == current.record.source_key and + wanted.root == current.record.root): + continue + if still_registered: + await client_review.remove_section(current.handler) + else: + # Dropped by the client (e.g. another provider held the slot); + # re-adding it below heals the section. + _log.warning('section %s of review %s was dropped; re-adding', + slug, review.review_id) + del served[slug] + + for slug, section in desired.items(): + if slug in served: + continue + handler = await asyncio.to_thread(self._build_handler, section) + await client_review.add_section(handler) + served[slug] = _ServedSection(section, handler) + + async def remove(self, review_id: uuid.UUID) -> None: + async with self._lock: + await self._remove(review_id) + + async def _remove(self, review_id: uuid.UUID) -> None: + client_review = self._reviews.pop(review_id, None) + self._served.pop(review_id, None) + if client_review is not None: + await self._session.remove_review(client_review) diff --git a/dev_tools/pylibs/edgacknowledg/forge/sandbox.py b/dev_tools/pylibs/edgacknowledg/forge/sandbox.py new file mode 100644 index 0000000000..3f1f1b9f08 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/sandbox.py @@ -0,0 +1,197 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Run commands over untrusted pull request content under bubblewrap. + +Sandboxed commands see a read-only /usr (plus the host's /bin, /lib* +layout), only the paths they are given, no network, and a cleared +environment, so neither the service's secrets nor the rest of the host are +reachable from them. +''' + +import asyncio +import os +import shutil +import signal + +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Optional, Sequence + +# Top-level directories programs need, mirrored as the host has them +# (directories or, on merged-/usr systems, symlinks into /usr). +_SYSTEM_DIRS = ('/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32') + +_DEFAULT_PATH = '/usr/local/bin:/usr/bin:/bin' + + +class SandboxUnavailableError(Exception): + '''Raised when sandboxing is required but bubblewrap can't be used.''' + + +@dataclass(frozen = True) +class CommandResult: + returncode: int + output: str + timed_out: bool + + @property + def succeeded(self) -> bool: + return self.returncode == 0 and not self.timed_out + + +class Sandbox: + '''Builds and runs (optionally) sandboxed commands. + + ``tools_dir`` is a dev_tools-style directory (bin/, pylibs/) whose tools + sandboxed commands may run: it is bound read-only, and its bin/ and + pylibs/ are put on PATH and PYTHONPATH. With ``enabled = False`` commands + run directly; that is for local development only. + ''' + + def __init__(self, + *, + enabled: bool, + tools_dir: Optional[Path] = None, + bwrap: str = 'bwrap') -> None: + self._enabled = enabled + self._tools_dir = tools_dir + self._bwrap = bwrap + + @property + def enabled(self) -> bool: + return self._enabled + + @property + def search_path(self) -> str: + if self._tools_dir is None: + return _DEFAULT_PATH + return f"{self._tools_dir / 'bin'}:{_DEFAULT_PATH}" + + def _base_env(self) -> dict[str, str]: + env = {'HOME': '/tmp', 'LC_ALL': 'C'} + if self._tools_dir is not None: + env['PYTHONPATH'] = str(self._tools_dir / 'pylibs') + return env + + async def check(self) -> None: + '''Raise ``SandboxUnavailableError`` unless sandboxed commands work.''' + if not self._enabled: + return + if shutil.which(self._bwrap) is None: + raise SandboxUnavailableError( + f"'{self._bwrap}' was not found; install bubblewrap (or, for local " + f"development only, disable sandboxing)" + ) + result = await self.run(['true'], timeout = 30) + if not result.succeeded: + raise SandboxUnavailableError( + f"bubblewrap failed (are unprivileged user namespaces enabled?): " + f"{result.output.strip()}" + ) + + def prefix(self, + *, + read_only: Sequence[Path] = (), + writable: Sequence[Path] = (), + env: Mapping[str, str] = {}, + cwd: Optional[Path] = None) -> list[str]: + '''Return the command prefix that runs a command in the sandbox. + + Later binds take precedence, so a writable directory may be nested in a + read-only one. + ''' + if not self._enabled: + return [] + + args = [ + self._bwrap, + '--unshare-all', + '--die-with-parent', + '--new-session', + '--ro-bind', '/usr', '/usr', + '--ro-bind-try', '/etc/alternatives', '/etc/alternatives', + '--ro-bind-try', '/etc/ld.so.cache', '/etc/ld.so.cache', + '--proc', '/proc', + '--dev', '/dev', + '--tmpfs', '/tmp', + ] + for system_dir in _SYSTEM_DIRS: + if os.path.islink(system_dir): + args += ['--symlink', os.readlink(system_dir), system_dir] + elif os.path.isdir(system_dir): + args += ['--ro-bind', system_dir, system_dir] + tools = [self._tools_dir] if self._tools_dir is not None else [] + for path in [*tools, *read_only]: + args += ['--ro-bind', str(path), str(path)] + for path in writable: + args += ['--bind', str(path), str(path)] + + args += ['--clearenv', '--setenv', 'PATH', self.search_path] + for name, value in {**self._base_env(), **env}.items(): + args += ['--setenv', name, value] + if cwd is not None: + args += ['--chdir', str(cwd)] + args.append('--') + return args + + async def run(self, + command: Sequence[str], + *, + read_only: Sequence[Path] = (), + writable: Sequence[Path] = (), + env: Mapping[str, str] = {}, + cwd: Optional[Path] = None, + timeout: float) -> CommandResult: + '''Run ``command`` (stdin closed, stderr merged into the output).''' + if self._enabled: + args = [ + *self.prefix( + read_only = read_only, + writable = writable, + env = env, + cwd = cwd + ), + *command + ] + process_env: Optional[dict[str, str]] = None + else: + args = list(command) + process_env = { + **self._base_env(), + 'PATH': f"{self.search_path}:{os.environ.get('PATH', '')}", + 'HOME': os.environ.get('HOME', '/tmp'), + **env, + } + + process = await asyncio.create_subprocess_exec( + *args, + stdin = asyncio.subprocess.DEVNULL, + stdout = asyncio.subprocess.PIPE, + stderr = asyncio.subprocess.STDOUT, + cwd = None if self._enabled else cwd, + env = process_env, + start_new_session = True + ) + try: + stdout, _ = await asyncio.wait_for(process.communicate(), timeout) + except asyncio.TimeoutError: + # The command leads its own session; take its children down with it. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + stdout, _ = await process.communicate() + return CommandResult( + returncode = -1, + output = stdout.decode(errors = 'replace'), + timed_out = True + ) + assert process.returncode is not None + return CommandResult( + returncode = process.returncode, + output = stdout.decode(errors = 'replace'), + timed_out = False + ) diff --git a/dev_tools/pylibs/edgacknowledg/forge/service.py b/dev_tools/pylibs/edgacknowledg/forge/service.py new file mode 100644 index 0000000000..5b97d49097 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/service.py @@ -0,0 +1,178 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Composition and entry point shared by every forge service. + +A forge service supplies its ``Forge`` (and optionally ``ForgeWebhooks`` and +``ForgeCommenter``) via ``main``; this module runs the rest: + +- the AcknowlEDG publisher (one batch connection), +- the processor (and, if enabled, the announcer after each sweep), +- the poller (always: at least the startup sync), +- the webhook receiver and its event worker (only if configured). +''' + +import asyncio +import logging +import signal +import sys + +from dataclasses import dataclass +from typing import Callable, Optional + +from edgacknowledg.forge import Forge, ForgeCommenter, ForgeWebhooks +from edgacknowledg.forge.announcer import Announcer +from edgacknowledg.forge.config import ConfigError, ForgeServiceConfig +from edgacknowledg.forge.db import ForgeStore +from edgacknowledg.forge.ingest import Ingestor +from edgacknowledg.forge.poller import run_poller +from edgacknowledg.forge.processor import Processor +from edgacknowledg.forge.publisher import AcknowledgPublisher +from edgacknowledg.forge.sandbox import Sandbox, SandboxUnavailableError +from edgacknowledg.forge.webhooks import EventQueue, create_app +from edgacknowledg.forge.workspace import Workspaces + +_log = logging.getLogger(__name__) + + +@dataclass +class ForgeComponents: + '''What a forge implementation provides.''' + forge: Forge + webhooks: Optional[ForgeWebhooks] = None + commenter: Optional[ForgeCommenter] = None + + +async def _serve_webhooks(app: object, bind: str) -> None: + # Only needed (and imported) when webhooks are enabled. + from hypercorn.asyncio import serve + from hypercorn.config import Config + + hypercorn_config = Config() + hypercorn_config.bind = [bind] + hypercorn_config.accesslog = None + # Log through the service's logging setup only (not twice). + hypercorn_config.errorlog = None + # Without a shutdown trigger Hypercorn installs its own SIGINT/SIGTERM + # handlers and shuts down alone; the service cancels it with everything + # else instead. + never = asyncio.Event() + await serve( + app, # type: ignore[arg-type] + hypercorn_config, + shutdown_trigger = never.wait + ) + + +async def run_service(config: ForgeServiceConfig, + components: ForgeComponents) -> None: + forge = components.forge + if config.webhook_bind is not None and components.webhooks is None: + raise ConfigError( + 'ACKNOWLEDG_FORGE_WEBHOOK_BIND is set, but webhooks are not ' + 'configured (is the webhook secret set?)' + ) + if config.comments and components.commenter is None: + raise ConfigError( + 'ACKNOWLEDG_FORGE_COMMENTS is set, but commenting is not configured ' + '(is the forge token set?)' + ) + + config.home.mkdir(parents = True, exist_ok = True) + sandbox = Sandbox(enabled = not config.unsandboxed, + tools_dir = config.tools_dir) + await sandbox.check() + if not sandbox.enabled: + _log.warning('sandboxing is DISABLED; for local development only') + + store = await ForgeStore.open(config.database_url) + try: + wake = asyncio.Event() + ingestor = Ingestor(store, forge, config, wake = wake) + publisher = AcknowledgPublisher( + api_host = config.acknowledg_api_host, + api_port = config.acknowledg_api_port, + api_key = config.acknowledg_api_key, + ui_host = config.acknowledg_ui_host, + ui_port = config.acknowledg_ui_port, + sandbox = sandbox + ) + + processor: Optional[Processor] = None + announcer: Optional[Announcer] = None + if config.comments and components.commenter is not None: + announcer = Announcer( + store = store, + forge_name = forge.name, + commenter = components.commenter, + config = config, + publisher = publisher, + busy = lambda: ( + processor.busy_pull_requests if processor is not None + else frozenset() + ) + ) + await announcer.start() + processor = Processor( + store = store, + forge = forge, + config = config, + workspaces = Workspaces(config.home, forge), + sandbox = sandbox, + publisher = publisher, + wake = wake, + after_sweep = announcer.sweep if announcer is not None else None + ) + + async with asyncio.TaskGroup() as task_group: + task_group.create_task(publisher.run()) + task_group.create_task(processor.run()) + task_group.create_task(run_poller(ingestor, config)) + if config.webhook_bind is not None: + assert components.webhooks is not None + events = EventQueue() + task_group.create_task(events.run(ingestor)) + task_group.create_task(_serve_webhooks( + create_app(components.webhooks, events), + config.webhook_bind + )) + _log.info('receiving webhooks on %s', config.webhook_bind) + finally: + await forge.aclose() + await store.aclose() + + +def main(build: Callable[[ForgeServiceConfig], ForgeComponents]) -> None: + '''Entry point for a forge service: read the configuration, build the + forge components with ``build``, and run until SIGINT/SIGTERM. + ''' + logging.basicConfig( + level = logging.INFO, + format = '%(levelname)s %(name)s: %(message)s' + ) + logging.getLogger('alembic').setLevel(logging.WARNING) + try: + config = ForgeServiceConfig.from_environment() + components = build(config) + except ConfigError as ex: + print(f"configuration error: {ex}", file = sys.stderr) + sys.exit(2) + + async def run() -> None: + task = asyncio.current_task() + assert task is not None + loop = asyncio.get_running_loop() + for signal_number in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(signal_number, task.cancel) + try: + await run_service(config, components) + except asyncio.CancelledError: + _log.info('shutting down') + + try: + asyncio.run(run()) + except (ConfigError, SandboxUnavailableError) as ex: + print(f"error: {ex}", file = sys.stderr) + sys.exit(2) diff --git a/dev_tools/pylibs/edgacknowledg/forge/webhooks.py b/dev_tools/pylibs/edgacknowledg/forge/webhooks.py new file mode 100644 index 0000000000..7359b5aac2 --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/webhooks.py @@ -0,0 +1,86 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Webhook receiver (optional): a small Quart app feeding the ingestor. + +Deliveries only identify what may have changed; the ingestor re-queries the +forge for the actual state. Events are queued and deduplicated so a burst of +deliveries can't fan out into unbounded forge API calls. +''' + +import asyncio +import logging + +from typing import Any + +from quart import Quart, request + +from edgacknowledg.forge import ( + CIRunTouched, + ForgeEvent, + ForgeWebhooks, + PullRequestTouched, +) +from edgacknowledg.forge.ingest import Ingestor + +_log = logging.getLogger(__name__) + +# GitHub caps deliveries at 25 MiB. +MAX_DELIVERY_BYTES = 25 * 1024 * 1024 + + +class EventQueue: + '''Pending forge events, each queued at most once until handled.''' + + def __init__(self) -> None: + self._queue: asyncio.Queue[ForgeEvent] = asyncio.Queue() + self._pending: set[ForgeEvent] = set() + + def put(self, event: ForgeEvent) -> None: + if event in self._pending: + return + self._pending.add(event) + self._queue.put_nowait(event) + + async def run(self, ingestor: Ingestor) -> None: + '''Handle events forever, one at a time.''' + while True: + event = await self._queue.get() + # Drop it from the pending set first: a delivery arriving while this + # one is handled must be handled again afterwards. + self._pending.discard(event) + try: + if isinstance(event, PullRequestTouched): + await ingestor.refresh_pull_request(event.repository, event.number) + elif isinstance(event, CIRunTouched): + await ingestor.refresh_ci_for_head(event.repository, event.head_sha) + except asyncio.CancelledError: + raise + except Exception: + # The next poll (or delivery) will catch up. + _log.exception('failed to handle %s', event) + + +def create_app(webhooks: ForgeWebhooks, events: EventQueue) -> Quart: + app = Quart(__name__) + app.config['MAX_CONTENT_LENGTH'] = MAX_DELIVERY_BYTES + + @app.post('/webhook') + async def receive_webhook() -> Any: + body = await request.get_data(cache = False, as_text = False) + assert isinstance(body, bytes) + headers = {key.lower(): value for key, value in request.headers.items()} + if not webhooks.verify(headers, body): + _log.warning('rejected a webhook delivery that failed verification') + return 'verification failed\n', 401 + for event in webhooks.parse(headers, body): + events.put(event) + return '', 202 + + @app.get('/healthz') + async def healthz() -> Any: + return 'ok\n', 200 + + return app diff --git a/dev_tools/pylibs/edgacknowledg/forge/workspace.py b/dev_tools/pylibs/edgacknowledg/forge/workspace.py new file mode 100644 index 0000000000..394f75da1e --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/workspace.py @@ -0,0 +1,523 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''On-disk workspaces: clones, worktrees, run directories, artifacts. + +Disk use is kept small because pull requests may stay open for a long time: + +- One bare, partial (blobless) clone per repository, shared by every review + of it, fetching only the refs the open pull requests need. +- Per-review worktrees with sparse checkouts listing individual tests (a + single test directory can hold thousands of unrelated tests), created + only when a section needs them. +- Run directories are swapped in by rename and deleted once superseded. + +Everything taken from a pull request or a CI artifact is untrusted: paths +are checked for symlinks before the service writes through them, and +archives are size-capped and may not contain links or escaping paths. +''' + +import asyncio +import base64 +import logging +import os +import re +import secrets +import shutil +import stat +import tarfile +import urllib.parse +import zipfile + +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Iterable, Optional, Sequence + +from edgacknowledg.forge import Forge, PullRequestInfo + +_log = logging.getLogger(__name__) + +# Test file extensions (see edgtest.DEFAULT_TEST_EXTENSIONS). +_TEST_EXTENSIONS = ('sft.cpp', 'sft.c', 'mft.cpp', 'mft.c') + +_DIFF_HEADER = re.compile(r'^diff --git a/(.*) b/(.*)$') + +# Paths every review worktree needs: the test home's configuration and +# expectations (both small). +_TEST_HOME_PATTERNS = ('/tests/.edgy/', '/tests/expectations/') + +BENCH_BASELINE_PATTERNS = ('/benchmarks/baselines/',) + + +class WorkspaceError(Exception): + '''Raised when a workspace operation fails or input is unsafe.''' + + +class UnsafeArchiveError(WorkspaceError): + '''Raised for an artifact that is too large or has unsafe entries.''' + + +@dataclass(frozen = True) +class GitResult: + returncode: int + stdout: str + stderr: str + + +def _escape_pattern(path: str) -> str: + '''Escape gitignore-style pattern characters in a literal path.''' + return re.sub(r'([\\*?\[\]!#])', r'\\\1', path) + + +def _test_base_names(file_name: str) -> list[str]: + '''Return the candidate base names of a test file (for its '..rto' + recordings directory), e.g. 'a.b.sft.cpp' -> ['a.b.sft', 'a.b', 'a']. + ''' + parts = file_name.split('.') + return ['.'.join(parts[:count]) for count in range(len(parts) - 1, 0, -1)] + + +def patterns_for_test(test_path: PurePosixPath) -> list[str]: + '''Sparse-checkout patterns for one test (a repository-relative path such + as 'tests/tests/suite/dir/name.sft.cpp'): the test file and its recordings, + or the whole directory of a multi-file test. + ''' + directory = _escape_pattern(str(test_path.parent)) + if '.mft.' in test_path.name: + return [f"/{directory}/"] + patterns = [f"/{_escape_pattern(str(test_path))}"] + for base_name in _test_base_names(test_path.name): + patterns.append(f"/{directory}/.{_escape_pattern(base_name)}.rto/") + return patterns + + +def _patterns_for_changed_test_file(path: PurePosixPath) -> list[str]: + '''Patterns covering a changed file under tests/tests/ and the test(s) it + belongs to (mirroring edg-rematerialize-test-run's lookup). + ''' + for index, part in enumerate(path.parts): + if part.startswith('.') and part.endswith('.rto'): + # A recording: '/..rto/...' belongs to '/.*'. + test_dir = _escape_pattern(str(PurePosixPath(*path.parts[:index]))) + base_name = _escape_pattern(part[1:-len('.rto')]) + return [ + f"/{test_dir}/.{base_name}.rto/", + f"/{test_dir}/{base_name}.*", + ] + if any(path.name.endswith(f".{ext}") for ext in _TEST_EXTENSIONS): + return patterns_for_test(path) + # An associated file of a multi-file test; those live in the test's own + # directory. + return [f"/{_escape_pattern(str(path.parent))}/"] + + +def changed_paths(diff: str) -> list[str]: + '''Return the paths a ``git diff --no-renames`` output touches.''' + paths = [] + for line in diff.splitlines(): + match = _DIFF_HEADER.match(line) + if match is not None: + paths.append(match.group(1)) + return paths + + +def changes_patterns(diff: str) -> list[str]: + '''Sparse-checkout patterns for rematerializing ``diff`` (of tests/) on + the base tree: the test home and every affected test. + ''' + patterns = list(_TEST_HOME_PATTERNS) + current_suite: Optional[str] = None + for line in diff.splitlines(): + match = _DIFF_HEADER.match(line) + if match is not None: + path = PurePosixPath(match.group(1)) + current_suite = None + if path.parts[:2] == ('tests', 'tests'): + patterns += _patterns_for_changed_test_file(path) + elif (path.parts[:2] == ('tests', 'expectations') and + path.suffix == '.log'): + # Expectation lines name tests relative to the suite. + current_suite = path.stem + continue + if current_suite is None or line.startswith(('---', '+++')): + continue + if line.startswith(('-', '+')): + test_name = line[1:].split(':')[0].strip() + if test_name: + patterns += patterns_for_test( + PurePosixPath('tests', 'tests', current_suite, test_name) + ) + return _unique(patterns) + + +def failures_patterns(test_ids: Iterable[str]) -> list[str]: + '''Sparse-checkout patterns for serving CI results naming ``test_ids`` + ('/', as in .elist files), plus bench baselines. + ''' + patterns = [*_TEST_HOME_PATTERNS, *BENCH_BASELINE_PATTERNS] + for test_id in test_ids: + test_path = PurePosixPath('tests', 'tests', test_id) + if '..' in test_path.parts or test_path.is_absolute(): + continue + patterns += patterns_for_test(test_path) + return _unique(patterns) + + +def _unique(items: Iterable[str]) -> list[str]: + return list(dict.fromkeys(items)) + + +def ensure_real_directory(root: Path, relative: PurePosixPath) -> Path: + '''Create ``root/relative`` one component at a time, refusing to go + through anything that isn't a real directory (e.g. a symlink a pull + request committed in place of 'tests/runs'). + ''' + current = root + for part in relative.parts: + if part in ('', '.', '..'): + raise WorkspaceError(f"invalid path component in {relative}") + current = current / part + try: + mode = os.lstat(current).st_mode + except FileNotFoundError: + current.mkdir() + continue + if not stat.S_ISDIR(mode): + raise WorkspaceError(f"{current} is not a real directory") + return current + + +def remove_tree(path: Path) -> None: + '''Remove a directory tree (without following symlinks), if present.''' + try: + mode = os.lstat(path).st_mode + except FileNotFoundError: + return + if stat.S_ISDIR(mode): + shutil.rmtree(path) + else: + path.unlink() + + +class _ByteBudget: + def __init__(self, limit: int) -> None: + self.remaining = limit + + def spend(self, count: int) -> None: + self.remaining -= count + if self.remaining < 0: + raise UnsafeArchiveError('artifact exceeds the extracted size limit') + + +def _check_inner_tarball(path: Path, budget: _ByteBudget) -> None: + '''Charge a (gzipped) tarball's decompressed size to ``budget``; reviewers' + requests decompress these, so they're bombs-in-waiting otherwise. + ''' + try: + with tarfile.open(path, 'r:*') as archive: + for member in archive: + if not (member.isfile() or member.isdir()): + raise UnsafeArchiveError(f"{path.name} contains a link or device") + budget.spend(member.size) + except tarfile.TarError as ex: + raise UnsafeArchiveError(f"{path.name} is not a valid tarball") from ex + + +def extract_artifact(zip_path: Path, + destination: Path, + *, + max_bytes: int) -> None: + '''Safely extract a CI artifact zip into the (new, empty) ``destination``. + + Rejects absolute or escaping paths and symlinks, and stops once more than + ``max_bytes`` would be written (counting the decompressed contents of + inner tarballs too). + ''' + budget = _ByteBudget(max_bytes) + tarballs = [] + try: + with zipfile.ZipFile(zip_path) as archive: + for info in archive.infolist(): + name = info.filename + member_path = PurePosixPath(name) + if (member_path.is_absolute() or '\\' in name or + any(part in ('..', '') for part in member_path.parts)): + raise UnsafeArchiveError(f"unsafe path in artifact: {name!r}") + if stat.S_ISLNK(info.external_attr >> 16): + raise UnsafeArchiveError(f"symlink in artifact: {name!r}") + + if info.is_dir(): + ensure_real_directory(destination, member_path) + continue + parent = ensure_real_directory(destination, member_path.parent) + target = parent / member_path.name + with archive.open(info) as source, open(target, 'xb') as sink: + while True: + chunk = source.read(1024 * 1024) + if not chunk: + break + budget.spend(len(chunk)) + sink.write(chunk) + if target.name.endswith(('.tar.gz', '.tgz', '.tar')): + tarballs.append(target) + except zipfile.BadZipFile as ex: + raise UnsafeArchiveError('artifact is not a valid zip archive') from ex + + for tarball in tarballs: + _check_inner_tarball(tarball, budget) + + +class Workspaces: + '''Clones and worktrees under ``home`` for one forge.''' + + def __init__(self, home: Path, forge: Forge) -> None: + self._home = home + self._forge = forge + self._repos_dir = home / 'repos' / forge.name + self._reviews_dir = home / 'reviews' + self._locks: dict[str, asyncio.Lock] = {} + + @property + def reviews_dir(self) -> Path: + return self._reviews_dir + + def clone_dir(self, repository: str) -> Path: + return self._repos_dir / f"{urllib.parse.quote(repository, safe = '')}.git" + + def review_dir(self, review_id: object) -> Path: + return self._reviews_dir / str(review_id) + + def lock(self, repository: str) -> asyncio.Lock: + '''The lock serializing git operations on ``repository``'s clone.''' + return self._locks.setdefault(repository, asyncio.Lock()) + + def _git_env(self) -> dict[str, str]: + env = { + 'PATH': os.environ.get('PATH', '/usr/bin:/bin'), + 'HOME': str(self._home), + 'LC_ALL': 'C', + 'GIT_TERMINAL_PROMPT': '0', + 'GIT_CONFIG_NOSYSTEM': '1', + 'GIT_CONFIG_GLOBAL': os.devnull, + } + # Pass credentials through the environment (GIT_CONFIG_*) rather than + # the command line, where other users could see them. + headers = list(self._forge.git_http_headers()) + env['GIT_CONFIG_COUNT'] = str(len(headers)) + for index, header in enumerate(headers): + env[f"GIT_CONFIG_KEY_{index}"] = 'http.extraHeader' + env[f"GIT_CONFIG_VALUE_{index}"] = header + return env + + async def git(self, + *args: str, + git_dir: Optional[Path] = None, + cwd: Optional[Path] = None, + stdin: Optional[str] = None, + check: bool = True) -> GitResult: + command = ['git'] + if git_dir is not None: + command += ['--git-dir', str(git_dir)] + command += list(args) + process = await asyncio.create_subprocess_exec( + *command, + stdin = ( + asyncio.subprocess.PIPE if stdin is not None + else asyncio.subprocess.DEVNULL + ), + stdout = asyncio.subprocess.PIPE, + stderr = asyncio.subprocess.PIPE, + cwd = cwd, + env = self._git_env() + ) + stdout, stderr = await process.communicate( + stdin.encode() if stdin is not None else None + ) + assert process.returncode is not None + result = GitResult( + process.returncode, + stdout.decode(errors = 'replace'), + stderr.decode(errors = 'replace') + ) + if check and result.returncode != 0: + raise WorkspaceError( + f"git {' '.join(args)} failed ({result.returncode}): " + f"{result.stderr.strip()}" + ) + return result + + async def _ensure_clone(self, repository: str) -> Path: + clone_dir = self.clone_dir(repository) + if clone_dir.exists(): + return clone_dir + _log.info('creating a clone of %s', repository) + self._repos_dir.mkdir(parents = True, exist_ok = True) + staging = clone_dir.with_name( + f"{clone_dir.name}.tmp-{secrets.token_hex(4)}" + ) + await self.git('init', '--quiet', '--bare', str(staging)) + for key, value in ( + ('core.repositoryformatversion', '1'), + ('remote.origin.url', self._forge.clone_url(repository)), + # Blobless: blobs are fetched on demand for the files a sparse + # checkout or diff actually needs. + ('remote.origin.promisor', 'true'), + ('remote.origin.partialclonefilter', 'blob:none'), + ('extensions.partialClone', 'origin'), + # Each worktree keeps its own sparse-checkout configuration. + ('extensions.worktreeConfig', 'true'), + ('core.hooksPath', os.devnull), + ('gc.autoDetach', 'false')): + await self.git('config', key, value, git_dir = staging) + # With per-worktree configuration, core.bare must be per-worktree too, + # or the linked worktrees inherit it and stop working (git-worktree(1)). + await self.git('config', '--unset', 'core.bare', git_dir = staging) + await self.git( + 'config', '--worktree', 'core.bare', 'true', + git_dir = staging + ) + staging.rename(clone_dir) + return clone_dir + + async def fetch_pull_request(self, info: PullRequestInfo) -> tuple[str, str]: + '''Fetch the pull request's head and base branch; return their SHAs.''' + async with self.lock(info.repository): + clone_dir = await self._ensure_clone(info.repository) + refs = f"refs/forge/{info.number}" + await self.git( + 'fetch', '--quiet', '--no-tags', '--filter=blob:none', 'origin', + f"+{info.fetch_ref}:{refs}/head", + f"+refs/heads/{info.base_ref}:{refs}/base", + git_dir = clone_dir + ) + head = await self.git('rev-parse', f"{refs}/head", git_dir = clone_dir) + base = await self.git('rev-parse', f"{refs}/base", git_dir = clone_dir) + return head.stdout.strip(), base.stdout.strip() + + async def fetch_commit(self, info: PullRequestInfo, sha: str) -> bool: + '''Fetch an arbitrary commit (e.g. the merge commit CI tested); return + whether the forge provided it. + ''' + async with self.lock(info.repository): + clone_dir = await self._ensure_clone(info.repository) + exists = await self.git( + 'cat-file', '-e', f"{sha}^{{commit}}", + git_dir = clone_dir, + check = False + ) + if exists.returncode == 0: + return True + result = await self.git( + 'fetch', '--quiet', '--no-tags', '--filter=blob:none', 'origin', + f"+{sha}:refs/forge/{info.number}/tested", + git_dir = clone_dir, + check = False + ) + return result.returncode == 0 + + async def merge_base(self, repository: str, base: str, head: str) -> str: + async with self.lock(repository): + result = await self.git( + 'merge-base', base, head, + git_dir = self.clone_dir(repository) + ) + return result.stdout.strip() + + async def diff(self, + repository: str, + base: str, + head: str, + paths: Sequence[str]) -> str: + async with self.lock(repository): + result = await self.git( + 'diff', '--no-renames', '--no-color', '--no-ext-diff', '--no-textconv', + base, head, '--', *paths, + git_dir = self.clone_dir(repository) + ) + return result.stdout + + async def checkout(self, + info: PullRequestInfo, + review_id: object, + name: str, + sha: str, + patterns: Sequence[str]) -> Path: + '''Check ``sha`` out sparsely (only ``patterns``) into the review's + ``name`` worktree, creating it if needed; return the worktree root. + + Untracked run directories in the worktree are left alone. + ''' + async with self.lock(info.repository): + clone_dir = await self._ensure_clone(info.repository) + review_dir = ensure_real_directory( + self._reviews_dir.parent, + PurePosixPath(self._reviews_dir.name, str(review_id)) + ) + worktree = review_dir / name + if not worktree.exists(): + await self.git( + 'worktree', 'add', '--quiet', '--no-checkout', '--detach', + str(worktree), sha, + git_dir = clone_dir + ) + await self.git( + 'sparse-checkout', 'set', '--no-cone', '--stdin', + cwd = worktree, + stdin = ''.join(f"{pattern}\n" for pattern in patterns) + ) + await self.git('reset', '--quiet', '--hard', sha, cwd = worktree) + return worktree + + async def remove_review(self, + info: PullRequestInfo, + review_id: object) -> None: + '''Remove a review's worktrees and directory, and the pull request's + fetched refs. + ''' + async with self.lock(info.repository): + clone_dir = self.clone_dir(info.repository) + review_dir = self.review_dir(review_id) + if clone_dir.exists(): + if review_dir.is_dir(): + for worktree in review_dir.iterdir(): + if (worktree / '.git').is_file(): + await self.git( + 'worktree', 'remove', '--force', '--force', str(worktree), + git_dir = clone_dir, + check = False + ) + refs = await self.git( + 'for-each-ref', '--format=%(refname)', + f"refs/forge/{info.number}/", + git_dir = clone_dir + ) + for ref in refs.stdout.split(): + await self.git('update-ref', '-d', ref, git_dir = clone_dir) + remove_tree(review_dir) + if clone_dir.exists(): + await self.git('worktree', 'prune', git_dir = clone_dir) + + async def collect_garbage(self, repository: str, open_count: int) -> None: + '''Delete ``repository``'s clone once no open pull request uses it + (``open_count`` comes from the database, so a pull request without a + worktree yet still keeps it); otherwise let git tidy it up. + ''' + async with self.lock(repository): + clone_dir = self.clone_dir(repository) + if not clone_dir.exists(): + return + if open_count == 0: + _log.info('removing the clone of %s (no open pull requests)', + repository) + remove_tree(clone_dir) + return + await self.git('worktree', 'prune', git_dir = clone_dir) + await self.git('gc', '--auto', '--quiet', git_dir = clone_dir) + + +def basic_auth_header(user: str, password: str) -> str: + '''An HTTP Basic 'Authorization' header (as git's http.extraHeader).''' + token = base64.b64encode(f"{user}:{password}".encode()).decode() + return f"Authorization: Basic {token}" diff --git a/dev_tools/pylibs/edgdocker/__init__.py b/dev_tools/pylibs/edgdocker/__init__.py index 36267a4b48..7e182f73ea 100644 --- a/dev_tools/pylibs/edgdocker/__init__.py +++ b/dev_tools/pylibs/edgdocker/__init__.py @@ -51,7 +51,8 @@ 'native-tools', 'legacy-native-tools', 'softfloat', - 'ci-runner' + 'ci-runner', + 'acknowledg-forge' ) def get_docker_file_dir(image_name: str) -> Path: diff --git a/dev_tools/pylibs/edgtest/__init__.py b/dev_tools/pylibs/edgtest/__init__.py index 540ee89837..46ce79ab47 100644 --- a/dev_tools/pylibs/edgtest/__init__.py +++ b/dev_tools/pylibs/edgtest/__init__.py @@ -7,7 +7,6 @@ import os import re import shutil -import subprocess import sys import tempfile @@ -173,15 +172,6 @@ def _is_test_file(file_path: Path, test_file_extensions: Set[str]) -> bool: return True -def _process_env_output(env_output: str) -> Dict[str, str]: - # Translate the output into a python friend mapping of environment variable - # values - suite_env = {} - for line in env_output.split('\n'): - parts = line.split('=') - suite_env[parts[0]] = '='.join(parts[1:]) - return suite_env - def _add_run_my_tests_env_vars(suite_env: Dict[str, str]) -> None: # Fallback to LC_ALL=C explicitly, as some tests don't work without this. if 'LC_ALL' not in suite_env: @@ -193,19 +183,14 @@ def _add_run_my_tests_env_vars(suite_env: Dict[str, str]) -> None: suite_env['CDISP'] = os.path.dirname(suite_env['CPFE']) def load_test_suite_env(test_suite_dir: Path) -> Dict[str, str]: - env_file = test_suite_dir / '.run_test_env' - - # Source the .run_my_test_environment file and dump the output - if env_file.exists(): - output = subprocess.check_output( - ['bash', '-c', f"source {env_file} && env"] - ).decode('utf-8') - # Get a python dictionary representation of the environment - suite_env = _process_env_output(output) - else: - # If no environment file is provided for the suite, inherit - # reasonable defaults. - suite_env = os.environ.copy() + '''Return the environment tests in the given suite run with. + + Suites used to be able to customize this with a `.run_test_env` shell + script, but sourcing it executes arbitrary code from the test tree (a + problem when serving untrusted pull requests), and no suite uses one, so + the environment is now always inherited. + ''' + suite_env = os.environ.copy() if 'RUN_TEST_DEFAULT_CONFIG' not in suite_env: suite_env['RUN_TEST_DEFAULT_CONFIG'] = runtest.get_default_config_name() diff --git a/dev_tools/services/acknowledg-forge/README.md b/dev_tools/services/acknowledg-forge/README.md new file mode 100644 index 0000000000..dbad391cf3 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/README.md @@ -0,0 +1,474 @@ +# acknowledg-forge + +acknowledg-forge connects a code forge (GitHub or GitLab today) to +[AcknowlEDG](../acknowledg/README.md). For every open pull request (merge +request) it keeps an AcknowlEDG review alive, using the same review id for the +pull request's whole life (including after it's reopened). Each review has +these sections, when they apply: + +| Section | Source | +| --- | --- | +| Test - Changes | The pull request's expectation and recording diffs, recreated with `edg-rematerialize-test-run` on the merge-base | +| Test - Failures | Unexpected differences from the latest CI run for the pull request's head (the `test-results-all` artifact) | +| Bench - C, Bench - C++ | The CI cachegrind results (the `bench-results` artifact), compared with the checked-in `benchmarks/baselines/project/release-{c,cpp}` | + +Optionally, a service account comments on the pull request whenever what's +served changes in a meaningful way: once the pull request has *settled* (CI for +its head finished, or `ACKNOWLEDG_FORGE_SETTLE_TIMEOUT` passed), with every +section that changed in a single comment. Restarts, closing, merging, and +reopening with the same results post nothing. + +## How it works + +``` + webhooks (optional) ─┐ + ├─► ingest ─► database ─► processor ─► AcknowlEDG + polling (always) ────┘ │ └────► comments (optional) + ▼ + clones, worktrees, run directories +``` + +- **Polling** always runs once at startup (catching up on anything missed + while the service was down) and then every `ACKNOWLEDG_FORGE_POLL_INTERVAL` + seconds, unless that is `0`. +- **Webhooks** are optional. A delivery only identifies what may have changed; + the service re-queries the forge, so polling and webhooks have the same + effect. +- **The database** (SQLite by default, PostgreSQL by URL) records each pull + request, its latest CI run, and what's served for it. The processor + reconciles each changed pull request: it builds missing or stale sections, + publishes exactly the sections that apply, and removes everything when the + pull request closes. +- **Disk use is kept small.** There is one blobless clone per repository. Each + review gets worktrees whose sparse checkouts list individual tests. Run + directories are deleted once superseded, and a clone is deleted when its + last open pull request closes. + +The shared, forge-independent code lives in +[`dev_tools/pylibs/edgacknowledg/forge`](../../pylibs/edgacknowledg/forge). +Each forge is a separate service here (`github/`, `gitlab/`) that implements +the `Forge`, `ForgeWebhooks`, and `ForgeCommenter` protocols. + +## Untrusted content + +Pull requests, and the CI artifacts their runs produce, are untrusted: + +- The EDG tooling the service runs comes from a frozen, reviewed copy + (`ACKNOWLEDG_FORGE_TOOLS_DIR`; the container image embeds one), never from a + pull request. A pull request's diff is only input to `patch`, applied to its + merge-base with a served branch (`ACKNOWLEDG_FORGE_BASE_BRANCHES`). +- Every command that reads pull request content (`edg-rematerialize-test-run` + and `patch`, plus `ifc-dump` and `cg_annotate` run for reviewers) runs under + [bubblewrap](https://github.com/containers/bubblewrap). It gets no network + and a cleared environment, sees only the files it needs, and can write only + its output directory. +- Artifacts are size-capped, including the decompressed size of the + `.rt.tar.gz` files inside them, and may not contain links or escaping paths. + The service refuses to write through symlinks a pull request committed. +- Sections are read-only. Reviewers can't accept diffs or update baselines + through the service. +- Comments contain only fixed text, commit hashes, and links the service + generates. + +## Host requirements + +The service normally runs in the `acknowledg-forge` container image +([`dev_tools/docker/acknowledg-forge`](../../docker/acknowledg-forge)). The +image pins every dependency: + +- Fedora's system packages (git, bubblewrap, patch, diffutils, valgrind); +- a frozen `dev_tools/bin` and `dev_tools/pylibs`; +- each service with its `uv.lock`-pinned Python dependencies. + +The host then needs only rootless Podman (with Quadlet), systemd, and network +access to the forge and the AcknowlEDG API. + +Running directly on a host instead needs Python 3.11 or later, git 2.35 or +later, bubblewrap (with unprivileged user namespaces), `python3`, `patch`, +`diff` and `cg_annotate` under `/usr`, and a `dev_tools` copy for +`ACKNOWLEDG_FORGE_TOOLS_DIR`. + +## Configuration + +Settings come from the environment. Secrets come from systemd credentials, or +from the environment variable shown as a fallback. + +| Variable | Default | Meaning | +| --- | --- | --- | +| `ACKNOWLEDG_FORGE_HOME` | (required; image: `/var/lib/acknowledg-forge`) | Storage root for clones, worktrees, and the default database | +| `ACKNOWLEDG_FORGE_TOOLS_DIR` | (required; image: `/edg/tools`) | A `dev_tools`-style directory (`bin/`, `pylibs/`) with the EDG tooling to run: `edg-rematerialize-test-run`, `ifc-dump` | +| `ACKNOWLEDG_FORGE_REPOSITORIES` | (required) | Comma-separated repositories: `owner/name` on GitHub, project paths on GitLab | +| `ACKNOWLEDG_FORGE_DATABASE_URL` | `sqlite+aiosqlite:///$HOME/state.db` | SQLAlchemy async URL, e.g. `postgresql+asyncpg://…` (the image includes the `postgresql` extra). Use the `database-url` credential instead when the URL holds a password | +| `ACKNOWLEDG_FORGE_BASE_BRANCHES` | `main` | Comma-separated target branches whose pull requests are served | +| `ACKNOWLEDG_FORGE_API_URL` | github.com / gitlab.com | Forge API URL, e.g. `https://ghe.example/api/v3` or `https://gitlab.example/api/v4` | +| `ACKNOWLEDG_FORGE_POLL_INTERVAL` | `300` | Seconds between polls; `0` polls only at startup | +| `ACKNOWLEDG_FORGE_WEBHOOK_BIND` | (unset) | `host:port` to receive webhooks on; unset runs no web server at all | +| `ACKNOWLEDG_FORGE_CI_WORKFLOW` | (unset) | CI to track: the GitHub workflow file (e.g. `build-and-test-ci.yml`), or the GitLab pipeline name (`*` for any). Unset disables CI tracking (and the Failures and Bench sections) | +| `ACKNOWLEDG_FORGE_TEST_ARTIFACT` | `test-results-all` | Test results artifact (GitLab: job) name | +| `ACKNOWLEDG_FORGE_BENCH_ARTIFACT` | `bench-results` | Benchmark results artifact (GitLab: job) name | +| `ACKNOWLEDG_FORGE_COMMENTS` | `0` | `1` enables comments (needs the forge token) | +| `ACKNOWLEDG_FORGE_SETTLE_TIMEOUT` | `7200` | Seconds after a push to stop waiting for CI before commenting | +| `ACKNOWLEDG_FORGE_MAX_ARTIFACT_BYTES` | 256 MiB | Largest artifact download | +| `ACKNOWLEDG_FORGE_MAX_EXTRACTED_BYTES` | 2 GiB | Largest extracted artifact (including inner tarballs) | +| `ACKNOWLEDG_FORGE_MAX_CONCURRENCY` | `2` | Pull requests processed at once | +| `ACKNOWLEDG_FORGE_REMATERIALIZE_TIMEOUT` | `900` | Seconds `edg-rematerialize-test-run` may run | +| `ACKNOWLEDG_FORGE_UNSANDBOXED` | `0` | `1` runs commands without bubblewrap. **Local development only.** | +| `EDG_ACKNOWLEDG_API_HOST`, `EDG_ACKNOWLEDG_API_PORT` | `localhost`, `2727` | AcknowlEDG API | +| `EDG_ACKNOWLEDG_UI_HOST`, `EDG_ACKNOWLEDG_UI_PORT` | `localhost`, `5173` | AcknowlEDG UI, used for links in comments | + +| Credential | Fallback variable | Meaning | +| --- | --- | --- | +| `forge-token` | `ACKNOWLEDG_FORGE_TOKEN` | Forge API token (also used for git) | +| `webhook-secret` | `ACKNOWLEDG_FORGE_WEBHOOK_SECRET` | Webhook secret; required for webhooks | +| `database-url` | `ACKNOWLEDG_FORGE_DATABASE_URL` | Database URL, for one holding a password (optional) | +| `acknowledg-api-key` | `EDG_ACKNOWLEDG_API_KEY` | AcknowlEDG API key, if the server requires one | + +### GitHub + +- **Token:** a fine-grained token (or a bot account's token) with read access + to *Contents*, *Pull requests*, and *Actions* on the configured + repositories. To comment, it also needs write access to *Pull requests*. +- **Webhook:** use content type `application/json`, the webhook secret, and + the *Pull requests* and *Workflow runs* events. Point it at + `https:///webhook`, typically behind a reverse proxy to + `ACKNOWLEDG_FORGE_WEBHOOK_BIND`. +- **CI:** set `ACKNOWLEDG_FORGE_CI_WORKFLOW=build-and-test-ci.yml`. Its + `publish-test-results` and `bench` jobs upload the artifacts, including the + `ci-meta.json` that records the tested (merge) commit. + +### GitLab + +- **Token:** a project or group access token with the `api` scope + (`read_api` is enough without comments). +- **Webhook:** use the secret token, with *Merge request events* and + *Pipeline events*. +- **CI:** artifacts belong to jobs, so `ACKNOWLEDG_FORGE_TEST_ARTIFACT` and + `ACKNOWLEDG_FORGE_BENCH_ARTIFACT` name the jobs whose artifacts archives hold + the results, laid out like the GitHub artifacts (configurations or + `c/`/`cpp/` at the top, plus `ci-meta.json`). + +## Deployment + +### Image + +The `acknowledg-forge Deploy` workflow +(`.github/workflows/acknowledg-forge-deploy.yml`) runs on pushes to `main` +that change the service or the tooling it freezes. It runs the tests, builds +the image with `build-edg-env acknowledg-forge`, and pushes it to +`ghcr.io/edgcpp/acknowledg-forge:latest`. Deployments reference the pushed +image by its digest. + +`prebuild.py` embeds that commit's `dev_tools/bin` and `dev_tools/pylibs`, and +builds both services' wheels with their locked requirements. The image is +public. It contains no credentials or deployment configuration, and the build +job has no access to the deployment's secrets. + +To build locally, run `build-edg-env acknowledg-forge`; the result is tagged +`docker.io/edgcpp/acknowledg-forge:latest`. + +### Host setup (once) + +The service runs as rootless Podman under its own host account, +`acknowledg-forge`, as a Quadlet *user* unit that the administrator owns in +`/etc/containers/systemd/users//`. Podman never runs as root. + +- **Credentials:** they stay in `/etc/credstore.encrypted`, root-owned. They + are scoped to the account, which may read them but not change them. +- **File ownership:** the container's service user is mapped to the account, + so everything the service writes (its database, clones, and worktrees) is + owned by `acknowledg-forge` on the host. +- **Everything else:** the container's other IDs, including its root, come + from the account's subordinate range, which no host user owns. + +Everything about the service except its image and AcknowlEDG API and UI +URLs is configured here, by the host's administrator. As root: + +1. Create the account, with a subordinate ID range for its containers, and + enable lingering so its user services run without a login. Note its UID + (`` below): + + ``` + useradd --system --create-home --home-dir /var/lib/acknowledg-forge \ + --shell /usr/sbin/nologin acknowledg-forge + usermod --add-subuids 300000-365535 --add-subgids 300000-365535 \ + acknowledg-forge + loginctl enable-linger acknowledg-forge + id -u acknowledg-forge + ``` + + The subordinate range must not overlap other `/etc/subuid` or + `/etc/subgid` entries. Podman keeps the account's images under its home. + +2. Create the state directory (or mount the storage for it there): + + ``` + install -d -m 0700 -o acknowledg-forge -g acknowledg-forge \ + /var/lib/acknowledg-forge/github + ``` + +3. Create the credentials. Scope each to the account (`--user --uid`), which + is the only one able to decrypt it, and let it read but not change them. + Each command prompts for the secret without echoing it. + + The forge token: + + ``` + mkdir -p /etc/credstore.encrypted + systemd-ask-password -n 'Forge token:' \ + | systemd-creds encrypt --user --uid=acknowledg-forge \ + --name=forge-token - \ + /etc/credstore.encrypted/acknowledg-forge.forge-token + chgrp acknowledg-forge /etc/credstore.encrypted/acknowledg-forge.forge-token + chmod 0640 /etc/credstore.encrypted/acknowledg-forge.forge-token + ``` + + The webhook secret (the same value as in the forge's webhook settings; for + example, generate one with `openssl rand -hex 32`): + + ``` + systemd-ask-password -n 'Webhook secret:' \ + | systemd-creds encrypt --user --uid=acknowledg-forge \ + --name=webhook-secret - \ + /etc/credstore.encrypted/acknowledg-forge.webhook-secret + chgrp acknowledg-forge \ + /etc/credstore.encrypted/acknowledg-forge.webhook-secret + chmod 0640 /etc/credstore.encrypted/acknowledg-forge.webhook-secret + ``` + + The AcknowlEDG API key, only if the AcknowlEDG server requires one (then + also uncomment its `LoadCredentialEncrypted=` line in the unit below): + + ``` + systemd-ask-password -n 'AcknowlEDG API key:' \ + | systemd-creds encrypt --user --uid=acknowledg-forge \ + --name=acknowledg-api-key - \ + /etc/credstore.encrypted/acknowledg-forge.acknowledg-api-key + chgrp acknowledg-forge \ + /etc/credstore.encrypted/acknowledg-forge.acknowledg-api-key + chmod 0640 /etc/credstore.encrypted/acknowledg-forge.acknowledg-api-key + ``` + + To rotate a secret, rerun its commands and restart the service: + + ``` + systemctl --user --machine=acknowledg-forge@ restart acknowledg-forge-github + ``` + +4. Optionally, use PostgreSQL instead of the default SQLite database (which + lives in the state directory). The service creates its schema on startup. + Switching gives the service a fresh database: it rebuilds what it serves + and adopts existing pull requests' results without commenting. + + On this host, connect over PostgreSQL's Unix socket with peer + authentication. The container's user is the host's `acknowledg-forge` + account, so no password is needed. Create its role and database, then + uncomment the unit's two "PostgreSQL on this host" lines: + + ``` + apt install postgresql + runuser -u postgres -- createuser acknowledg-forge + runuser -u postgres -- createdb --owner=acknowledg-forge acknowledg_forge + ``` + + For a remote server, the URL holds a password, so store it as a credential + instead and uncomment the unit's `database-url` line: + + ``` + systemd-ask-password -n 'Database URL:' \ + | systemd-creds encrypt --user --uid=acknowledg-forge \ + --name=database-url - \ + /etc/credstore.encrypted/acknowledg-forge.database-url + chgrp acknowledg-forge \ + /etc/credstore.encrypted/acknowledg-forge.database-url + chmod 0640 /etc/credstore.encrypted/acknowledg-forge.database-url + ``` + + Enter it as + `postgresql+asyncpg://:@:/`. + +5. Save the unit below as + `/etc/containers/systemd/users//acknowledg-forge-github.container`, + replacing the repository. Then load it: + + ``` + systemctl --user --machine=acknowledg-forge@ daemon-reload + ``` + +``` +[Unit] +Description=acknowledg-forge (GitHub) +After=network-online.target +Wants=network-online.target + +[Container] +# Deployments override the image (pinned by digest) in a drop-in; see below. +Image=ghcr.io/edgcpp/acknowledg-forge:latest +ContainerName=acknowledg-forge-github +Exec=acknowledg-forge-github +# The container's 'acknowledg' user (1000), which runs the service, is the +# host's acknowledg-forge account. +UserNS=keep-id:uid=1000,gid=1000 +# bubblewrap inside the container mounts its own /proc, which Podman's +# masking of /proc paths prevents. +PodmanArgs=--security-opt unmask=/proc/* +Volume=/var/lib/acknowledg-forge/github:/var/lib/acknowledg-forge:Z +# The credentials systemd decrypted for this unit. +Volume=%d:/run/credentials:ro +Environment=CREDENTIALS_DIRECTORY=/run/credentials +Environment=ACKNOWLEDG_FORGE_REPOSITORIES=/ +Environment=ACKNOWLEDG_FORGE_CI_WORKFLOW=build-and-test-ci.yml +Environment=ACKNOWLEDG_FORGE_COMMENTS=1 +Environment=ACKNOWLEDG_FORGE_WEBHOOK_BIND=0.0.0.0:2728 +PublishPort=127.0.0.1:2728:2728 +# Optional: PostgreSQL on this host, over its Unix socket with peer +# authentication (see step 4). +#Volume=/run/postgresql:/run/postgresql +#Environment=ACKNOWLEDG_FORGE_DATABASE_URL=postgresql+asyncpg://acknowledg-forge@/acknowledg_forge?host=/run/postgresql + +[Service] +LoadCredentialEncrypted=forge-token:/etc/credstore.encrypted/acknowledg-forge.forge-token +LoadCredentialEncrypted=webhook-secret:/etc/credstore.encrypted/acknowledg-forge.webhook-secret +#LoadCredentialEncrypted=acknowledg-api-key:/etc/credstore.encrypted/acknowledg-forge.acknowledg-api-key +# Optional: a remote PostgreSQL, whose URL (with its password) is a credential +# (see step 4). +#LoadCredentialEncrypted=database-url:/etc/credstore.encrypted/acknowledg-forge.database-url +# Starting may include pulling the image. +TimeoutStartSec=600 +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target +``` + +For GitLab, use `Exec=acknowledg-forge-gitlab` with its own unit name and +state directory. + +### Deploying from CI + +The workflow's `deploy` job connects to the host over SSH as a deploy user, +which needs no other access to the host. Install the deploy helper, and let +that user run it, and only it, as root: + +``` +install -m 0755 -o root -g root \ + dev_tools/services/acknowledg-forge/deploy-remote.sh \ + /usr/local/libexec/acknowledg-forge-deploy +echo ' ALL=(root) NOPASSWD: /usr/local/libexec/acknowledg-forge-deploy' \ + > /etc/sudoers.d/acknowledg-forge +``` + +The helper ([`deploy-remote.sh`](deploy-remote.sh)) manages a single root-owned +drop-in next to the unit, +`acknowledg-forge-github.container.d/50-ci-deploy.conf`, which sets the unit's +`Image=` and the `EDG_ACKNOWLEDG_{API,UI}_{HOST,PORT}` the AcknowlEDG URLs +translate to. It pulls the image into the `acknowledg-forge` account's storage +and drives that account's user services +(`systemctl --user --machine=acknowledg-forge@`). + +- **Strict arguments:** it accepts only images from + `ghcr.io/edgcpp/acknowledg-forge` pinned by digest, `ws(s)://` and + `http(s)://` URLs with no path, and a service named `acknowledg-forge-*`. + The service picks TLS by port alone, so the helper also rejects URLs it + couldn't honor exactly: TLS on a port other than 443, or plain + `ws://`/`http://` on 443. A leaked deploy key can therefore only + deploy another published image. +- **What it does:** it pulls the image and restarts the service only if the + drop-in changed (otherwise it just makes sure the service is running), then + checks that the service stays up. +- **What it leaves alone:** the base unit and the credentials. +- **Updates:** the installed copy changes only when the administrator + reinstalls it. + +The deploy job reads these secrets from the `acknowledg-forge` GitHub +environment: + +| Secret | Purpose | +| --- | --- | +| `ACKNOWLEDG_FORGE_SSH_HOST` | Host name or IP address | +| `ACKNOWLEDG_FORGE_SSH_USER` | The deploy user | +| `ACKNOWLEDG_FORGE_SSH_PORT` | SSH port (optional; defaults to 22) | +| `ACKNOWLEDG_FORGE_SSH_PRIVATE_KEY` | Private key for that user | +| `ACKNOWLEDG_FORGE_SSH_KNOWN_HOSTS` | `ssh-keyscan` output for the host | +| `ACKNOWLEDG_FORGE_SERVICE` | The unit's name (optional; defaults to `acknowledg-forge-github`) | + +And these **repository variables** (public, so not secrets; the API URL is +shared with the AcknowlEDG deploy, which builds the web client with it): + +| Variable | Purpose | +| --- | --- | +| `ACKNOWLEDG_API_HOST` | Public AcknowlEDG API URL, e.g. `wss://acknowledg-api.example` | +| `ACKNOWLEDG_UI_HOST` | Public AcknowlEDG UI URL, e.g. `https://acknowledg.example`, for links in comments | + +The host needs Podman with Quadlet drop-in support (tested with Podman 5.4) +and systemd 256 or later (for user-scoped encrypted credentials). +The first +published package on GHCR is private; make it public in the package's +settings so the host can pull it without logging in. + +Notes: + +- **Isolation:** the container keeps the service and its dependencies away + from the host. bubblewrap still isolates the commands that read pull request + content from the service inside the container: from its credentials, its + database, and other reviews. `unmask=/proc/*` only affects what the + container's `/proc` shows. In a rootless container those files stay + unreadable to it. +- **Shutdown:** stopping the service (SIGTERM) shuts it down cleanly. Because + state lives in the database and workspaces on the volume, a restart, or a + newer image, restores what was served without rebuilding it or commenting. +- **One instance:** run only one instance per AcknowlEDG server. AcknowlEDG + allows a single provider per review section. + +### Without a container + +Install a service wheel (`build.sh`) into a virtual environment and run +`acknowledg-forge-github` (or `-gitlab`) with the same settings, plus +`ACKNOWLEDG_FORGE_HOME` and `ACKNOWLEDG_FORGE_TOOLS_DIR`. Under systemd, pass +secrets with `LoadCredentialEncrypted=`, as for the +[AcknowlEDG server](../acknowledg/README.md#server). Don't add +`RestrictNamespaces=` or `PrivateUsers=`, which would stop bubblewrap. + +## Development + +``` +github/setup-venv.sh # or gitlab/ +tests/run-tests.sh # pytest: both services and the shared code +typecheck.sh # mypy, as strict as typecheck-python.sh +``` + +To try the service locally, start the AcknowlEDG server and client +([../acknowledg](../acknowledg/README.md)). Then run `github/run-service.sh` +with these set: + +- `ACKNOWLEDG_FORGE_HOME`; +- `ACKNOWLEDG_FORGE_REPOSITORIES`; +- `ACKNOWLEDG_FORGE_TOOLS_DIR` (e.g. this checkout's `dev_tools`); +- a token. + +Without bubblewrap, also set `ACKNOWLEDG_FORGE_UNSANDBOXED=1`. + +Database schema changes go through Alembic. Add a revision under +`edgacknowledg/forge/db/migrations/versions/` (see `migrations/env.py`). + +### Adding a forge + +Implement `Forge`, and optionally `ForgeWebhooks` and `ForgeCommenter` +(`edgacknowledg/forge/__init__.py`). `edgacknowledg.forge.http` handles the +REST plumbing: rate limits, pagination, and credential-free downloads. Then +add a service directory like `github/`, whose `__main__` passes a +`build(config)` to `edgacknowledg.forge.service.main`. + +## Known limitations + +- `edg-rematerialize-test-run` currently writes `changes.elog` but no + `changes.elist`, which the review tooling needs. Until that's fixed, the + service logs a warning and serves no **Test - Changes** section. +- On GitHub, pull request CI tests the merge commit. Failures and benchmarks + are served against that commit when it can still be fetched, and against the + pull request's head otherwise. +- The `ci-runner` image doesn't include valgrind yet, so the CI `bench` job + installs it at run time. +- The tooling is frozen into the image, so a change to the test-home or + expectations layout that `edg-rematerialize-test-run` must follow needs a + new image. diff --git a/dev_tools/services/acknowledg-forge/deploy-remote.sh b/dev_tools/services/acknowledg-forge/deploy-remote.sh new file mode 100644 index 0000000000..d1ecab375b --- /dev/null +++ b/dev_tools/services/acknowledg-forge/deploy-remote.sh @@ -0,0 +1,162 @@ +#!/usr/bin/bash + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# Point an acknowledg-forge Quadlet service at a new image and restart it. +# +# Installed by the host's administrator as a root-owned helper (e.g. +# /usr/local/libexec/acknowledg-forge-deploy) that the deploy user may run +# with sudo, and nothing else as root: +# +# sudo /usr/local/libexec/acknowledg-forge-deploy \ +# +# +# The URLs are the public AcknowlEDG endpoints, e.g. wss://api.example and +# https://ui.example (the same values the web client is built with). +# +# The service is a rootless Podman user service of SERVICE_USER, whose unit +# the administrator owns, +# /etc/containers/systemd/users//.container (see README.md), +# along with its credentials. This script only manages a drop-in next to it +# that sets the image (pinned by digest, from IMAGE_REPOSITORY only) and the +# EDG_ACKNOWLEDG_{API,UI}_{HOST,PORT} the URLs translate to, and restarts the +# service only when that drop-in changes. Since it runs as root on a caller's +# behalf, every argument is strictly validated. + +set -euo pipefail + +# The only repository images may come from. +IMAGE_REPOSITORY='ghcr.io/edgcpp/acknowledg-forge' +# The account the service runs as. +SERVICE_USER='acknowledg-forge' + +if [[ $# -ne 4 ]]; then + echo "usage: $0 " \ + '' >&2 + exit 2 +fi + +service=$1 +image=$2 +api_url=$3 +ui_url=$4 + +fail() { + echo "error: $*" >&2 + exit 1 +} + +[[ ${EUID} -eq 0 ]] || fail 'must run as root (via sudo)' +[[ "${service}" =~ ^acknowledg-forge-[a-z0-9-]+$ ]] \ + || fail 'the service must be named acknowledg-forge-' +[[ "${image}" =~ ^${IMAGE_REPOSITORY//./\\.}@sha256:[0-9a-f]{64}$ ]] \ + || fail "the image must be ${IMAGE_REPOSITORY}, pinned by digest" + +# Split ${3} (a URL) into url_host and url_port. ${1} and ${2} are the plain +# and TLS schemes it may use (e.g. ws and wss). The service picks TLS by port +# alone (443), so only URLs it can honor exactly are accepted: TLS on 443 and +# plain on any other port, never a silent downgrade. +parse_url() { + local plain=$1 tls=$2 url=$3 label=$4 + local pattern="^(${plain}|${tls})://" + pattern+='([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?)(:([0-9]{1,5}))?/?$' + [[ "${url}" =~ ${pattern} ]] \ + || fail "the AcknowlEDG ${label} must be ${plain}:// or ${tls}://" \ + 'followed by a host name and optional port, with no path' + local scheme=${BASH_REMATCH[1]} + url_host=${BASH_REMATCH[2]} + url_port=${BASH_REMATCH[5]} + if [[ -n "${url_port}" ]] && + (( 10#${url_port} < 1 || 10#${url_port} > 65535 )); then + fail "the AcknowlEDG ${label} has an invalid port" + fi + url_port=${url_port:+$((10#${url_port}))} + if [[ "${scheme}" == "${tls}" ]]; then + url_port=${url_port:-443} + [[ ${url_port} -eq 443 ]] \ + || fail "the AcknowlEDG ${label} can only use ${tls}:// on port 443" + else + url_port=${url_port:-80} + [[ ${url_port} -ne 443 ]] \ + || fail "the AcknowlEDG ${label} must use ${tls}:// on port 443" + fi +} +parse_url ws wss "${api_url}" 'API URL' +api_host=${url_host} +api_port=${url_port} +parse_url http https "${ui_url}" 'UI URL' +ui_host=${url_host} +ui_port=${url_port} + +uid="$(id -u "${SERVICE_USER}")" || fail "no ${SERVICE_USER} account" +if [[ "$(loginctl show-user "${SERVICE_USER}" --property=Linger --value)" \ + != yes ]]; then + fail "lingering is off for ${SERVICE_USER}" \ + "(loginctl enable-linger ${SERVICE_USER})" +fi +unit_dir="/etc/containers/systemd/users/${uid}" +[[ -f "${unit_dir}/${service}.container" ]] \ + || fail "${unit_dir}/${service}.container is missing; set the service" \ + 'up first (see README.md)' + +# Run a command as the service's account, in its user session. +as_service_user() { + runuser -u "${SERVICE_USER}" -- \ + env XDG_RUNTIME_DIR="/run/user/${uid}" \ + sh -c 'cd && exec "$@"' sh "$@" +} + +user_systemctl() { + systemctl --user --machine="${SERVICE_USER}@" "$@" +} + +# The drop-in stays root-owned: the account can't change what it runs. +drop_in_dir="${unit_dir}/${service}.container.d" +drop_in="${drop_in_dir}/50-ci-deploy.conf" +content="\ +# Managed by the acknowledg-forge-deploy workflow; changes are overwritten. +[Container] +Image=${image} +Environment=EDG_ACKNOWLEDG_API_HOST=${api_host} +Environment=EDG_ACKNOWLEDG_API_PORT=${api_port} +Environment=EDG_ACKNOWLEDG_UI_HOST=${ui_host} +Environment=EDG_ACKNOWLEDG_UI_PORT=${ui_port} +" +changed=0 +if [[ ! -f "${drop_in}" ]] || ! cmp -s "${drop_in}" <(printf '%s' "${content}") +then + install -d -m 0755 "${drop_in_dir}" + printf '%s' "${content}" > "${drop_in}.tmp" + chmod 0644 "${drop_in}.tmp" + mv "${drop_in}.tmp" "${drop_in}" + changed=1 +fi + +echo "Pulling ${image##*@}." +as_service_user podman pull --quiet "${image}" > /dev/null + +user_systemctl daemon-reload +if [[ ${changed} -eq 1 ]]; then + echo "Restarting ${service}." + user_systemctl restart "${service}.service" +elif ! user_systemctl is-active --quiet "${service}.service"; then + echo "Starting ${service}." + user_systemctl start "${service}.service" +else + echo "Unchanged; ${service} keeps running." +fi + +# Give it a moment to fail on startup (bad configuration, missing access). +sleep 5 +if ! user_systemctl is-active --quiet "${service}.service"; then + echo "error: ${service} is not active" >&2 + journalctl "_UID=${uid}" --user-unit "${service}.service" --lines 30 \ + --no-pager >&2 || true + exit 1 +fi + +as_service_user podman image prune --force > /dev/null +echo "Deployed ${service} (${image##*@})." diff --git a/dev_tools/services/acknowledg-forge/github/.gitignore b/dev_tools/services/acknowledg-forge/github/.gitignore new file mode 100644 index 0000000000..add0d12103 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/.gitignore @@ -0,0 +1,8 @@ +.venv/ +app-venv/ +build/ +dist/ + +*.egg-info/ + +__pycache__/ diff --git a/dev_tools/services/acknowledg-forge/github/acknowledg_forge_github/__init__.py b/dev_tools/services/acknowledg-forge/github/acknowledg_forge_github/__init__.py new file mode 100644 index 0000000000..55b3dd76e7 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/acknowledg_forge_github/__init__.py @@ -0,0 +1,291 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''acknowledg-forge for GitHub (and GitHub Enterprise Server). + +Implements the ``edgacknowledg.forge`` protocols over the GitHub REST API: +pull requests, GitHub Actions workflow runs and artifacts, issue comments, +and ``pull_request`` / ``workflow_run`` webhooks. +''' + +import hashlib +import hmac +import json + +from datetime import datetime +from pathlib import Path +from typing import Any, Mapping, Optional, Sequence + +import httpx + +from edgacknowledg.forge import ( + ArtifactInfo, + ArtifactTooLargeError, + CIConclusion, + CIRunInfo, + CIRunTouched, + CIStatus, + ForgeError, + ForgeEvent, + PullRequestInfo, + PullRequestState, + PullRequestTouched, +) +from edgacknowledg.forge.http import ForgeHttpClient +from edgacknowledg.forge.workspace import basic_auth_header + +DEFAULT_API_URL = 'https://api.github.com' +DEFAULT_WEB_URL = 'https://github.com' + +_API_VERSION = '2022-11-28' + +_CONCLUSIONS = { + 'success': CIConclusion.SUCCESS, + 'failure': CIConclusion.FAILURE, + 'timed_out': CIConclusion.FAILURE, + 'startup_failure': CIConclusion.FAILURE, + 'cancelled': CIConclusion.CANCELLED, +} + + +def _parse_time(value: str) -> datetime: + return datetime.fromisoformat(value) + + +def web_url_for(api_url: str) -> str: + '''The web (and git) base URL for an API URL: github.com's API lives on + api.github.com; GitHub Enterprise Server's under /api/v3. + ''' + if api_url.rstrip('/') == DEFAULT_API_URL: + return DEFAULT_WEB_URL + return api_url.rstrip('/').removesuffix('/api/v3') + + +class GitHubClient(ForgeHttpClient): + '''Authenticated GitHub REST access.''' + + def __init__(self, + *, + api_url: str = DEFAULT_API_URL, + token: Optional[str], + transport: Optional[httpx.AsyncBaseTransport] = None) -> None: + headers = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': _API_VERSION, + } + if token: + headers['Authorization'] = f"Bearer {token}" + super().__init__(base_url = api_url, headers = headers, + transport = transport) + self.token = token + + +class GitHubForge: + '''``Forge`` over the GitHub REST API.''' + + name = 'github' + + def __init__(self, + client: GitHubClient, + *, + workflow: Optional[str]) -> None: + self._client = client + self._workflow = workflow + self._web_url = web_url_for(client.base_url) + + def clone_url(self, repository: str) -> str: + return f"{self._web_url}/{repository}.git" + + def git_http_headers(self) -> Sequence[str]: + if not self._client.token: + return [] + return [basic_auth_header('x-access-token', self._client.token)] + + def _pull_request(self, repository: str, payload: Any) -> PullRequestInfo: + if payload['state'] == 'open': + state = PullRequestState.OPEN + elif payload.get('merged_at'): + state = PullRequestState.MERGED + else: + state = PullRequestState.CLOSED + return PullRequestInfo( + repository = repository, + number = int(payload['number']), + state = state, + title = str(payload['title']), + author = str((payload.get('user') or {}).get('login', '')), + url = str(payload['html_url']), + base_ref = str(payload['base']['ref']), + base_sha = str(payload['base']['sha']), + head_sha = str(payload['head']['sha']), + fetch_ref = f"refs/pull/{int(payload['number'])}/head", + updated_at = _parse_time(payload['updated_at']), + ) + + async def list_open_pull_requests(self, + repository: str) -> list[PullRequestInfo]: + payloads = await self._client.paginate( + f"/repos/{repository}/pulls", + {'state': 'open'} + ) + return [self._pull_request(repository, payload) for payload in payloads] + + async def get_pull_request(self, + repository: str, + number: int) -> PullRequestInfo: + payload = await self._client.get_json( + f"/repos/{repository}/pulls/{number}" + ) + return self._pull_request(repository, payload) + + async def latest_ci_run(self, + pull_request: PullRequestInfo + ) -> Optional[CIRunInfo]: + if self._workflow is None: + return None + repository = pull_request.repository + payload = await self._client.get_json( + f"/repos/{repository}/actions/workflows/{self._workflow}/runs", + { + 'head_sha': pull_request.head_sha, + 'event': 'pull_request', + 'per_page': 1, + } + ) + runs = payload.get('workflow_runs') or [] + if len(runs) == 0: + return None + run = runs[0] + + if run['status'] == 'completed': + status = CIStatus.COMPLETED + conclusion: Optional[CIConclusion] = _CONCLUSIONS.get( + str(run.get('conclusion')), + CIConclusion.OTHER + ) + artifacts = tuple( + ArtifactInfo( + name = str(artifact['name']), + id = str(artifact['id']), + size = int(artifact.get('size_in_bytes', 0)), + digest = artifact.get('digest'), + expired = bool(artifact.get('expired', False)), + ) + for artifact in await self._client.paginate( + f"/repos/{repository}/actions/runs/{run['id']}/artifacts", + key = 'artifacts' + ) + ) + else: + status = ( + CIStatus.RUNNING if run['status'] == 'in_progress' + else CIStatus.PENDING + ) + conclusion = None + artifacts = () + return CIRunInfo( + run_id = str(run['id']), + attempt = int(run.get('run_attempt', 1)), + head_sha = str(run['head_sha']), + status = status, + conclusion = conclusion, + completed_at = ( + _parse_time(run['updated_at']) if status == CIStatus.COMPLETED + else None + ), + url = str(run['html_url']), + artifacts = artifacts, + ) + + async def download_artifact(self, + repository: str, + artifact: ArtifactInfo, + destination: Path, + *, + max_bytes: int) -> None: + if artifact.size > max_bytes: + raise ArtifactTooLargeError( + f"artifact {artifact.name} is {artifact.size} bytes" + ) + await self._client.download( + f"/repos/{repository}/actions/artifacts/{artifact.id}/zip", + destination, + max_bytes = max_bytes + ) + + async def aclose(self) -> None: + await self._client.aclose() + + +class GitHubWebhooks: + '''``ForgeWebhooks`` for GitHub's signed deliveries.''' + + def __init__(self, secret: str) -> None: + self._secret = secret.encode() + + def verify(self, headers: Mapping[str, str], body: bytes) -> bool: + signature = headers.get('x-hub-signature-256', '') + expected = 'sha256=' + hmac.new( + self._secret, + body, + hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(signature.encode(), expected.encode()) + + def parse(self, + headers: Mapping[str, str], + body: bytes) -> list[ForgeEvent]: + event = headers.get('x-github-event') + if event not in ('pull_request', 'workflow_run'): + return [] + payload = json.loads(body) + repository = str(payload['repository']['full_name']) + if event == 'pull_request': + return [PullRequestTouched(repository, int(payload['number']))] + return [ + CIRunTouched(repository, str(payload['workflow_run']['head_sha'])) + ] + + +class GitHubCommenter: + '''``ForgeCommenter`` posting issue comments as the token's account.''' + + def __init__(self, client: GitHubClient) -> None: + self._client = client + self._login: Optional[str] = None + + async def post_comment(self, + repository: str, + number: int, + body: str) -> str: + response = await self._client.request( + 'POST', + f"/repos/{repository}/issues/{number}/comments", + json_body = {'body': body} + ) + return str(response.json()['id']) + + async def _own_login(self) -> Optional[str]: + if self._login is None: + try: + self._login = str((await self._client.get_json('/user'))['login']) + except ForgeError: + # E.g. a GitHub App installation token; match on the marker alone. + self._login = '' + return self._login or None + + async def find_comment(self, + repository: str, + number: int, + marker: str) -> Optional[str]: + login = await self._own_login() + for comment in await self._client.paginate( + f"/repos/{repository}/issues/{number}/comments"): + author = (comment.get('user') or {}).get('login') + if login is not None and author != login: + continue + if marker in str(comment.get('body', '')): + return str(comment['id']) + return None diff --git a/dev_tools/services/acknowledg-forge/github/acknowledg_forge_github/__main__.py b/dev_tools/services/acknowledg-forge/github/acknowledg_forge_github/__main__.py new file mode 100644 index 0000000000..bede2d9b03 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/acknowledg_forge_github/__main__.py @@ -0,0 +1,40 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Run acknowledg-forge against GitHub (configuration: see README.md).''' + +from edgacknowledg.forge import service +from edgacknowledg.forge.config import ForgeServiceConfig + +from acknowledg_forge_github import ( + DEFAULT_API_URL, + GitHubClient, + GitHubCommenter, + GitHubForge, + GitHubWebhooks, +) + + +def build(config: ForgeServiceConfig) -> service.ForgeComponents: + client = GitHubClient( + api_url = config.api_url or DEFAULT_API_URL, + token = config.forge_token + ) + return service.ForgeComponents( + forge = GitHubForge(client, workflow = config.ci_workflow), + webhooks = ( + GitHubWebhooks(config.webhook_secret) if config.webhook_secret + else None + ), + commenter = GitHubCommenter(client) if config.forge_token else None, + ) + + +def main() -> None: + service.main(build) + + +if __name__ == '__main__': + main() diff --git a/dev_tools/services/acknowledg-forge/github/build.sh b/dev_tools/services/acknowledg-forge/github/build.sh new file mode 100755 index 0000000000..c09a5c80a4 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/build.sh @@ -0,0 +1,8 @@ +#!/usr/bin/bash + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +uv build diff --git a/dev_tools/services/acknowledg-forge/github/edgacknowledg b/dev_tools/services/acknowledg-forge/github/edgacknowledg new file mode 120000 index 0000000000..452c595d86 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/edgacknowledg @@ -0,0 +1 @@ +../../../pylibs/edgacknowledg \ No newline at end of file diff --git a/dev_tools/services/acknowledg-forge/github/edgtest b/dev_tools/services/acknowledg-forge/github/edgtest new file mode 120000 index 0000000000..24d14b80da --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/edgtest @@ -0,0 +1 @@ +../../../pylibs/edgtest \ No newline at end of file diff --git a/dev_tools/services/acknowledg-forge/github/edgutil b/dev_tools/services/acknowledg-forge/github/edgutil new file mode 120000 index 0000000000..c8493c7e4d --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/edgutil @@ -0,0 +1 @@ +../../../pylibs/edgutil \ No newline at end of file diff --git a/dev_tools/services/acknowledg-forge/github/pyproject.toml b/dev_tools/services/acknowledg-forge/github/pyproject.toml new file mode 100644 index 0000000000..988118676a --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "edg-acknowledg-forge-github" +version = "2026.10.15.0" +requires-python = ">=3.11" +dependencies = [ + "aiosqlite", + "alembic", + "httpx", + "hypercorn", + "quart", + "sqlalchemy[asyncio]>=2.0", + "websockets", +] + +[project.optional-dependencies] +postgresql = ["asyncpg"] + +[project.scripts] +acknowledg-forge-github = "acknowledg_forge_github.__main__:main" + +[dependency-groups] +dev = ["pytest", "pytest-asyncio"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = [ + "acknowledg_forge_github*", + "edgacknowledg*", + "edgtest*", + "edgutil*", +] + +[tool.setuptools.package-data] +"edgacknowledg.forge.db" = ["migrations/script.py.mako"] diff --git a/dev_tools/services/acknowledg-forge/github/run-service.sh b/dev_tools/services/acknowledg-forge/github/run-service.sh new file mode 100755 index 0000000000..dfa39fd853 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/run-service.sh @@ -0,0 +1,12 @@ +#!/usr/bin/bash + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# Run acknowledg-forge-github for local development. Configuration comes +# from the environment (see ../README.md); at least ACKNOWLEDG_FORGE_HOME and +# ACKNOWLEDG_FORGE_REPOSITORIES must be set. + +exec uv run acknowledg-forge-github "$@" diff --git a/dev_tools/services/acknowledg-forge/github/setup-venv.sh b/dev_tools/services/acknowledg-forge/github/setup-venv.sh new file mode 100755 index 0000000000..7bc514267b --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/setup-venv.sh @@ -0,0 +1,8 @@ +#!/usr/bin/bash + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +uv sync diff --git a/dev_tools/services/acknowledg-forge/github/uv.lock b/dev_tools/services/acknowledg-forge/github/uv.lock new file mode 100644 index 0000000000..73b6951ba0 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/github/uv.lock @@ -0,0 +1,946 @@ +version = 1 +revision = 5 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/4e/59dc964f962f09e3ed472e5d2d3ba670a41a2be25080dc62ab3db507ff5e/asyncpg-0.32.0.tar.gz", hash = "sha256:45e64e56714d888330b884aad1dfb363d0bf43fb343e3d1a8968525f3bade478", size = 1075156, upload-time = "2026-10-06T20:32:40.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/27/1a7970f1ece6c205b03c79f45b89420dee9655ffb66bd2c11be8f40c248a/asyncpg-0.32.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5789340b9bcdab94a19eb8ff119322a09991e3626d131b55828535b373e285d4", size = 686071, upload-time = "2026-10-06T20:30:39.115Z" }, + { url = "https://files.pythonhosted.org/packages/2b/47/085934d0290806a92789eee860109c44bea71ff8bc7850a9d3a30da7a819/asyncpg-0.32.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:057ed2455e4e14ad9949f1ac1829112c7d0454c9810b124f36de1486febe6824", size = 692193, upload-time = "2026-10-06T20:30:40.563Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2c/d92524b9e860aecd119c0ebe43f3b9eca26dc2b75c4dfe1be3e999e3f6b1/asyncpg-0.32.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c938c4da9166ac1ef330475e314e2b94c68bde2795be0f4e8a1e00ccd806cadd", size = 3196713, upload-time = "2026-10-06T20:30:42.123Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/3ac7cb86aa287e5bbceaeb783ee6e4f51cd2a001f1747ef4f1236a20bde6/asyncpg-0.32.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:968c570c5913b7ce0995953d7239bd2367142d1af4359f87699f7a6ca75c4382", size = 3260618, upload-time = "2026-10-06T20:30:43.552Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/618ac36b2970b437d45523f50b5580dba0c34756bbf2153306f82a2697e5/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96c8226d2026e025852facb5a05035ea5e11b14bebb6b42e4e43948ef8f0d075", size = 3132973, upload-time = "2026-10-06T20:30:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/54db41b3d5fe26b0401a49327ffce439195c5f6073d8afbbdc9758cb35c3/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d3f745f4947df9004e2637753ff81d52f305f790f49d67f72e1677db12b07a7b", size = 3251612, upload-time = "2026-10-06T20:30:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e0/ed1e7536ce949896de29ee955b473659b3daa7887e7081030dba2b15ea5d/asyncpg-0.32.0-cp311-cp311-win32.whl", hash = "sha256:469e6520a839957304582eb8a708d874985914500b64517155f80e6fec00e742", size = 538739, upload-time = "2026-10-06T20:30:48.355Z" }, + { url = "https://files.pythonhosted.org/packages/df/eb/52c4bddad17ff1bee485ae83e08c752a998ef04ac5df76f03fef6430d0ed/asyncpg-0.32.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a1e671e67f4b0bef3c03f37a896d61706f769a83922c119070f1f04e415dc17", size = 610534, upload-time = "2026-10-06T20:30:50.003Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/9af12f2b3300c425a151ef8f85f47c0db76135827c549031858954805ff7/asyncpg-0.32.0-cp311-cp311-win_arm64.whl", hash = "sha256:901bc87b94539f32853bd73a9b02fa78f7feed4cf628824caad3093ec6662f58", size = 574363, upload-time = "2026-10-06T20:30:51.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/06/d5f956db9c936c90cd3289cf948a86c3efc9849e26354356c23da29f6a2d/asyncpg-0.32.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7cb31f7a8472ddc6b6f5c9da1290e901d5c77c8441c7213bd13b13ef6fe6359c", size = 681566, upload-time = "2026-10-06T20:30:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/ea55f3b26fd40ec90e5b6d6c53b9ff52633cf6b87a468d9c033a727832f4/asyncpg-0.32.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:643d8d6e955a355045dddfe827d74f4f0d1dc4a18e06963a08260af838fbf093", size = 704359, upload-time = "2026-10-06T20:30:54.608Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/a3704e8675d37b168f3584661fc9f64f3021659c9b94e51cf9ab957b2bc5/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:14ff79ca2574182ce258159c48978a086f9026fc121d935017b5d10c64fa3c72", size = 3707008, upload-time = "2026-10-06T20:30:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/30/30/4fd8d1155b3d7a32a2c241dcb9c5d9e9bd74a59ae71ed25ef8ddb8e038e1/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:54851411bee2aa51a30d0911524201fbb05f82cc0f7c248b140203db637c723d", size = 3810163, upload-time = "2026-10-06T20:30:58.114Z" }, + { url = "https://files.pythonhosted.org/packages/c1/25/5b0992d45661e1488aba775cf17a2e6c82c7d1d7e10acc71efd394760a00/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8592f0ed9c315b2117dbdc707cf3292f09a89d5b07661016a84dd881326965cf", size = 3600446, upload-time = "2026-10-06T20:30:59.946Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/1c82c6feacec813423401b5aef1a43baea951694157f4d405b2d14e80e6d/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4dbe0982cb3ded878de0867dfaeae3116faf471d484ea28b3e3da942f01fb778", size = 3764563, upload-time = "2026-10-06T20:31:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/84/f5/5a3796088f0c3f7d22aaf7c48536f40b27e44b7c9603d4d7abfeca2ed97e/asyncpg-0.32.0-cp312-cp312-win32.whl", hash = "sha256:fbe1f8c788fb5df18ea8a5432dfa2473fd8f7f088025fb83d089a7c7b37e37b0", size = 551810, upload-time = "2026-10-06T20:31:03.248Z" }, + { url = "https://files.pythonhosted.org/packages/af/42/f4d333a3f67b0e7cf58ea855f9d5d9104ce38c21f2a2f22bf7dce524428c/asyncpg-0.32.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd7157a86817730c3239bc687abf8186a471525d695e225c187b9a523a808a98", size = 626763, upload-time = "2026-10-06T20:31:04.927Z" }, + { url = "https://files.pythonhosted.org/packages/a8/82/9d82e16e1d0b4e2a639a2db649d4b444b8a479cd52553a9c36ba0d6320a8/asyncpg-0.32.0-cp312-cp312-win_arm64.whl", hash = "sha256:9509e21fc526f1fc27cf80ad9f9b8dde3f3e21935d46be66d649635321d3407c", size = 577288, upload-time = "2026-10-06T20:31:06.776Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/b6b5870b51e004880d9a216313ea7d4f180961c5869f32e58e8cb9b71e96/asyncpg-0.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c032869fd9c3c9fd1a86ad67e53f63906159068087c2674dd1e19be3cffff571", size = 683362, upload-time = "2026-10-06T20:31:08.078Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/1f450742bc6eab0c015cae26aef94fac2ff29433e3f18a019126c3912c49/asyncpg-0.32.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:0c764dce865b41878396e736d4d2c6c6ce3a8e1b61d1f6bb292e30d265ae7ca6", size = 706652, upload-time = "2026-10-06T20:31:09.524Z" }, + { url = "https://files.pythonhosted.org/packages/05/dc/13f3c0ef7e867bafdccd470e5cfae1f2fd9a7085c771546bd4b94018e043/asyncpg-0.32.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:925ce1cc54419d468bfb77632d91e5e2be5be0fdf9d43680c68fe7cedf87051a", size = 3698244, upload-time = "2026-10-06T20:31:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/b00ef3fc0d861c28a1937f08d2c7f6e6119c152b414d50fa800c3aee83b5/asyncpg-0.32.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cec40b66a36b14921c155db78631cd96ed00e225fdf38dd5532e9aef350a498", size = 3801314, upload-time = "2026-10-06T20:31:12.964Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/215067d97a13206ce1565da920ddbefe5a1e5f89903e6de862fdd0a034a1/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fba43a9a230ce4d2b4593b761b8e03630c613c282b24566e27c7f53695273b1", size = 3598650, upload-time = "2026-10-06T20:31:14.797Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/2bfcb5c9b04df3f17fd367647c9f3ee9fe64ea0612b509a6b1832afcedae/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a8f7fa8304f757e23cccb8ffef6a6fce0b6320ffc565a884ee3cd0dfad1ac5", size = 3762739, upload-time = "2026-10-06T20:31:17.186Z" }, + { url = "https://files.pythonhosted.org/packages/08/45/e6b37756e6c8979fe070e9821654244f38319493f5b0589e549d9a40c001/asyncpg-0.32.0-cp313-cp313-win32.whl", hash = "sha256:d809399022e244eb86bb532a4ae9a45746e0f6dc5154fd6aa2f6ad63fa3f5373", size = 551065, upload-time = "2026-10-06T20:31:18.812Z" }, + { url = "https://files.pythonhosted.org/packages/ee/46/0a4e92f4310da644b28595b22ef2fff1ffd3dab84953dc8b4c5eef72b764/asyncpg-0.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:38640b106705fef8b0f46cdb5fd9dcf6a638eed5cadb0f441714a21405ca8a0a", size = 625571, upload-time = "2026-10-06T20:31:20.571Z" }, + { url = "https://files.pythonhosted.org/packages/35/f4/48ed4b580b99b1fabc480c707229bb8f1e4ba0f5b24a50822b339efe1e48/asyncpg-0.32.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78145adedfe51dc2fda623e6602cf816dabc2eafcff693bd50484321a1c9034", size = 576342, upload-time = "2026-10-06T20:31:22.29Z" }, + { url = "https://files.pythonhosted.org/packages/25/25/a30ca6417f9142c6a63a7caf5f33717902b2d0ca8a8ff8fc72c6cc2fa77d/asyncpg-0.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ac18d9ee7a8ca70aed276f79b249d9f37e4d55e3525db1002b5f0b62ddec4f5", size = 691699, upload-time = "2026-10-06T20:31:24.168Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b5/59f10f2381a073c199cd868fce0d8f7aa448b08412de4dc4dbe4118bcee9/asyncpg-0.32.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e1120ef2ae3a5e514c9ea9fce83519ba692710ea5f38434eadbbf12789073dfe", size = 715194, upload-time = "2026-10-06T20:31:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/59/79a5aebd58250bedefa6dcd43b22b037d9cf0054ceb4c718c53ebf04e63f/asyncpg-0.32.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa68acb42f22436597016e5d7feef7b0b5c49b4c56aece3fdb3ba0da2326cb2", size = 3729978, upload-time = "2026-10-06T20:31:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/68/db/fc91b503b3ec66cf242d83c799388285ea5f0ee238435d53dd9c1a8648a9/asyncpg-0.32.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63417b8f7369c54f6754c1fbd5a2968fbe632ff55bfbedd56a0177b6a96bd251", size = 3794539, upload-time = "2026-10-06T20:31:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/40/bd/7359320499fdb2733206191b8fd15b7ec602656cbc1444bff7a8c66a365c/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c6366841a792d0a4d16991de240a8053b7c4772a18a5f27fa6fad09c0e359fb", size = 3632884, upload-time = "2026-10-06T20:31:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/18/75/dd3c3dd99f1db55b9736d23a44da29501f07f852bf4df91507f37b156fb1/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3ef1dfd11919280e011ffd1c873323c5088a94fd2c3f77946a5250cf306e2eb", size = 3764931, upload-time = "2026-10-06T20:31:32.916Z" }, + { url = "https://files.pythonhosted.org/packages/38/4f/161b275759725a774d170a383c1208996865ebad50d6891e60d35461a3e6/asyncpg-0.32.0-cp314-cp314-win32.whl", hash = "sha256:77cf9d7023f063ae6f9e443077b55af0dc1807dd9afff1ae656b93ee0cddedc9", size = 557690, upload-time = "2026-10-06T20:31:34.856Z" }, + { url = "https://files.pythonhosted.org/packages/b5/03/880d0db1faedf8b740a57a7ba50e115651a0f05c5905140195813879b086/asyncpg-0.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f87452025b47ce80dcc3a0be2b5d1f8aab5deec2516d266f1643d4e53cc40d5", size = 634859, upload-time = "2026-10-06T20:31:36.512Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/2e86b462a2a2a795eaa7838266db019876b8e7a12c465b903517a4e87fd0/asyncpg-0.32.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0e4508a3d62b0f42d7a99c030c364050b11e75f61c9dd4861e5fdda7cb60636", size = 594013, upload-time = "2026-10-06T20:31:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/5369c4438496e654121cbda75be2e8043d1fcae3552b856d44011a19b723/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afec11e0b9c001e69966becacd2f948cc8949b4916ec4c0f4dc9b52e47de4528", size = 743832, upload-time = "2026-10-06T20:31:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/4b92582c2339a164275a6418ccaeeb0453b72f2e0d7003702379cb50e852/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:418d266a553e932bf961bb43bfd610ee6c5425fb1b9a599a5828fd12bae8f5c4", size = 769568, upload-time = "2026-10-06T20:31:40.691Z" }, + { url = "https://files.pythonhosted.org/packages/3d/88/919d9ff7ca3c3b96aa404b88b6a53e142b4422623c5ee5a69c4b733240ce/asyncpg-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1666e1b747ebbc75c87cb31972704ae8a3ca15b950f94456e97d26781c67d10", size = 3948962, upload-time = "2026-10-06T20:31:42.456Z" }, + { url = "https://files.pythonhosted.org/packages/27/8b/e9f412ae9a3e3f0eb23415249e8d5933e7aeb01068b4083fc86714043d1f/asyncpg-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83510bb25d38f0415e155aa3a7af78621369891f5ecd8730d012d9cb26143ffc", size = 3874815, upload-time = "2026-10-06T20:31:44.094Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/24364e9ff7bb9860548452513f295306b12f5b24e8fb0b78f1605c443946/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87957755d11639cf248c6aaa094eee9d150f07065866d1710c9427e02dfc0790", size = 3762465, upload-time = "2026-10-06T20:31:45.908Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/33cb7e805ec6806b196473e2c7a2ba9d5af3ad2928930aa06359c8eeef87/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:764227423bf30a3001d3da6df90e82d30a2a097d762e4ee5fa074236eda262f4", size = 3797285, upload-time = "2026-10-06T20:31:47.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/e7/85eb86d6040725f5c191fd6af9f10769c60ed971634b47f4b4bcab293d44/asyncpg-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:f2342b1f3e87b2096320a77edcbb830fbd23b1d4d4842c57567764430b95e4fc", size = 594006, upload-time = "2026-10-06T20:31:49.197Z" }, + { url = "https://files.pythonhosted.org/packages/f9/aa/ea75defe55718457bcf41cde42248db5bbee65fce8c6f0a0e43d9eca1723/asyncpg-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c3a48908cb0a02393e5bdab7fa92aefd700f2a93212bf91f04aa9657b4f554d", size = 674647, upload-time = "2026-10-06T20:31:50.547Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0b/078d362872c6c72dd5d11c214dde8dac65b1c87ece96fd2fc2f786a8f66c/asyncpg-0.32.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f8eadd207c26850a2e15f3c2a1096b5d051ea6758a26f2f3e65ce16f84297ed8", size = 624589, upload-time = "2026-10-06T20:31:52.291Z" }, + { url = "https://files.pythonhosted.org/packages/5c/83/e0145d19197b965438693179c88dd99cfc69bc1bf954815f44762ab88843/asyncpg-0.32.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58975b1a51a100c4716ebf22f84c249d27140f7b9385b64ad9b676836f1db9ab", size = 689708, upload-time = "2026-10-06T20:31:55.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/13/f394919a59f104288b1b17fb6c7a3ac4738b8c555690a63caf603f91ca83/asyncpg-0.32.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:6b95fc2ebdb4af072bfa8b64c6d0397b49242d17bef1c0337857904f9267dab2", size = 714408, upload-time = "2026-10-06T20:31:57.504Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/1123cf41bff78fdfd80e6fd143cc86bf1ef2875af8f5d8742c03f471e913/asyncpg-0.32.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a759f98c5652443db501b20041aeee548e9a04fe7ae939067321acd207218447", size = 3733440, upload-time = "2026-10-06T20:31:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/ff4b045e85d7bdf6f61f67c285800abd6e82f26319671d7f0dfadadc1aa0/asyncpg-0.32.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceea1064500d0d7a46c092cdbe9752064c23b720ab0e0bff83d1030fffe7a50a", size = 3824312, upload-time = "2026-10-06T20:32:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/1ec7eb6e20f7e8ae120a41aad9669044cce964f39773baf644897a046aee/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:543f02790d086244c7cdc849e4b671b6c2048be0242b78d943494da6e80c0001", size = 3637212, upload-time = "2026-10-06T20:32:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/79/68/528e362eb5adbc1a7defe4c5f157756a031346d3efa9920467b245e4ce41/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f24d20a68f0e37ca6fc490388e7eeb48abab3da0dbf06248135ed6179f5f521d", size = 3791355, upload-time = "2026-10-06T20:32:04.415Z" }, + { url = "https://files.pythonhosted.org/packages/38/e3/22f443f456bf93d1806f43a820da8ee463dfe9b93a9d77a3f00fedcdaad6/asyncpg-0.32.0-cp315-cp315-win32.whl", hash = "sha256:110f72d33c8b944ab421ca383db0b8849cfeb861547fee6cbb61f65a6bcd0985", size = 557457, upload-time = "2026-10-06T20:32:06.52Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/ccb76555a333f543c4d6ad6422b616efc0811dbbde5054fda071e249c7bf/asyncpg-0.32.0-cp315-cp315-win_amd64.whl", hash = "sha256:6d1d1cd1348ebb9b204b5f56f977c5d4380674c25cc094064bf32bd9c3b7273d", size = 635573, upload-time = "2026-10-06T20:32:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/38/70/dff17e837ba0eb4347bb33da33f54df87230d3d176793d4bb2ad7786b1b8/asyncpg-0.32.0-cp315-cp315-win_arm64.whl", hash = "sha256:cd5d16b3a5db37c1e6e445e362952b4af569f85f94e162f947bfa8ea25a45fa5", size = 594218, upload-time = "2026-10-06T20:32:09.717Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b8/c5506dbde0cfb213963210fd0c80e60036ddaaa883ac0d3c55d05a10ebe8/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ea1a72a00fe705b68a9727c3d538c4c56690af9bb1cbbf3c089f5d3ddcccea0", size = 741693, upload-time = "2026-10-06T20:32:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/9f998c651aa5d66b59ab6c13da71a15d74ccb1ddc4d65290ea5e2e5aedc1/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:ed3ae4c3659aea1fb0e3a6c1061fc4c64d9b7a2a8f4a27443dc43d74fa84cf03", size = 768101, upload-time = "2026-10-06T20:32:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ce/d8c63a71e908f5d80de1a3a057c8407aaea07cf19980d4b24ab624943c99/asyncpg-0.32.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db69b9cf879bddeea41210c80b8c8877bfe2709e2bee9d18d5a5c00e7eb75972", size = 3940715, upload-time = "2026-10-06T20:32:14.544Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a5/5d2b17682e297e39206eda1dfe0120fc239e84d3440b39ff7c9cc7ec83db/asyncpg-0.32.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bee7bb5394bf55fc3bf4144625c33f298949961acdb1e0d67e60f958ac9a2e6", size = 3907504, upload-time = "2026-10-06T20:32:16.212Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/38ec7277f31f26267a0a0547d0997d936850d05007d1e0e1041bf8070e1d/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d74eabd68e68861333e3fcb92b520a2a851f6485abf4b723887590399d4980c1", size = 3750324, upload-time = "2026-10-06T20:32:18.061Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/089e80eda7d543a49875687a84121e2ad61a7c69698963623ee77372c4e9/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6af2af292a93d5ef800007c8f8f66b85af2a49b49e4b56a10685a0dc24a6af83", size = 3826457, upload-time = "2026-10-06T20:32:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/38104e60cda6131977f95b634d45536ddc1cde53ef8bc765f9056e3e17ee/asyncpg-0.32.0-cp315-cp315t-win32.whl", hash = "sha256:d148cb6a9081ed999ca3cd0d95fb9eaf79bf17d885bba93c83de52273d2fe0af", size = 592437, upload-time = "2026-10-06T20:32:21.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/09/85cba249db0910708826ea428b32a4a05630df993621c369bdb8d42c73c5/asyncpg-0.32.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e101801b4124e905da0732cf2b0d838f682a9ea5273d7cced3d54bdbe744e6f7", size = 672417, upload-time = "2026-10-06T20:32:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/38/11/ec5f7f306dd361aa9558f002cbb6acfa1e9ba32fa59b8f53135fbdfa14f1/asyncpg-0.32.0-cp315-cp315t-win_arm64.whl", hash = "sha256:3bbf08c08e31f43be858255614518e78cdfb343571e557e818e9fe736334f4c8", size = 622767, upload-time = "2026-10-06T20:32:24.64Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[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 = "edg-acknowledg-forge-github" +version = "2026.10.15.0" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "alembic" }, + { name = "httpx" }, + { name = "hypercorn" }, + { name = "quart", version = "0.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "quart", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "websockets" }, +] + +[package.optional-dependencies] +postgresql = [ + { name = "asyncpg" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite" }, + { name = "alembic" }, + { name = "asyncpg", marker = "extra == 'postgresql'" }, + { name = "httpx" }, + { name = "hypercorn" }, + { name = "quart" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, + { name = "websockets" }, +] +provides-extras = ["postgresql"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d7/41511ee2696f14be4200b524d9553dc4295e2bdeb20aa8962c3cb25e71c6/greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324", size = 294075, upload-time = "2026-09-14T14:25:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7b/b509624970909294cd064ff7346148ca9941c21bec9026d7873dd254e9fa/greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa", size = 613429, upload-time = "2026-09-14T15:12:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5c/d2eb503067f9ba20875ef8c87681f29a64f53bbbbe4059a5d7c53179d442/greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2", size = 625405, upload-time = "2026-09-14T15:20:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/9b071d11c8bb9f5f38cccacc38fcc234d91997a4c395cc2bf43ecae89642/greenlet-3.5.6-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4fb8e59f68845d56c23c031dcd79c329f345e4a9d2ffac91c3d1ab366bdc457b", size = 633270, upload-time = "2026-09-14T15:25:04.864Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/63d4477ce31dff2fd802a9a20240f6606aac85977e0fb18443aae33de3f6/greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba", size = 624428, upload-time = "2026-09-14T14:35:56.895Z" }, + { url = "https://files.pythonhosted.org/packages/88/17/ac11883ecc9da19c681c8b763ee39e6f7dca2aa81874eb11a075d3cbeb00/greenlet-3.5.6-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d701eab36200c36224833d07dbdb709adb7fd4253429548ddb5e547b8ed40586", size = 428068, upload-time = "2026-09-14T15:28:35.872Z" }, + { url = "https://files.pythonhosted.org/packages/ad/aa/9cde4e00688eaa2a03b91d12e4681439a87e6aad860399e0847af6a014ca/greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae", size = 1588385, upload-time = "2026-09-14T15:10:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/24632b5ec186b64e21e07a8f53ce5e15a7e9cb33eddee99a5fe16379afa5/greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13", size = 1653119, upload-time = "2026-09-14T14:35:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/019d2ef898f4b9ac845167f1c6f73229e9a4e2439362a5e2ce50205a19b0/greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016", size = 323317, upload-time = "2026-09-14T14:22:38.836Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/439df999455e3bdf02b1c68f3848d4020385ef0a01f89f706b07bf148a65/greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32", size = 307739, upload-time = "2026-09-14T14:23:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + +[[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 = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[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 = "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 = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "mako" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "quart" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.13'", +] +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/8a/13962df31309fa024b1811102981577b1702916779d3f17067bbf1f7691d/quart-0.22.0.tar.gz", hash = "sha256:6ba567bb29e0ea66f7c0a0297c2b6225bb531e37dbf9b75dbf4a6e1713c4c934", size = 65475, upload-time = "2026-08-19T19:53:30.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/80/0159d6fe2fc76915f2354e5b9187082987f7d648f0298d49770320c086ef/quart-0.22.0-py3-none-any.whl", hash = "sha256:bb659545f1a8a287a14df9434b9225a3d4738362a3ed170744d0e03bb9447b50", size = 78912, upload-time = "2026-08-19T19:53:28.961Z" }, +] + +[[package]] +name = "quart" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", +] +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/81/34396f67e09e7a0609261f1ef0f43b26f5d67e8f2dc4d34b4953061560f2/quart-0.23.1.tar.gz", hash = "sha256:1ca848415910bd2eb75e9d9b452388f892a37be222602a373622e6c633d1efbf", size = 65636, upload-time = "2026-08-29T15:58:35.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/c1/26dca56249da1a889ebb946000ab272712476209234f714ad3e8013ee005/quart-0.23.1-py3-none-any.whl", hash = "sha256:78cf3a7249ab09f9e03d78b0b5e2472c4c09ce4615a99c2b1aa9a35261243b66", size = 79388, upload-time = "2026-08-29T15:58:34.147Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/44/311bac6b6ef81e4dfd0287d04900108b1f5c00c9761dd3c0a2b7b9d0f86b/sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd", size = 10544216, upload-time = "2026-10-07T17:33:59.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/cd/264493ea522b887ac71949d442ef6a49ca04504e1090b427e878a71d5bb2/sqlalchemy-2.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6d147c31e189541ae7cd990482c4f960f9e8abce186551225fa355856dbf1a5", size = 2463376, upload-time = "2026-10-07T18:17:21.503Z" }, + { url = "https://files.pythonhosted.org/packages/59/16/1dbc3674709e945d113cfe0f652431cfeda0aa5999c0737444e7e4a416f8/sqlalchemy-2.1.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55072780d1aae84dea443ce27edeb745f6cc4d19ad89416abbb6b49712080e7c", size = 4628615, upload-time = "2026-10-07T18:37:37.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/24/0640dfb48fde362b83eaa122691457cb9a13f51d50cd6064ddcfba667c71/sqlalchemy-2.1.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:343a0493a81278bfe30be1ec81214a55f2f44aaa4662d230be359ab2aa18cc2a", size = 4650872, upload-time = "2026-10-07T18:24:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e4/5aec21a9e6ffadc919854fef1cd92b6f699ee204811e78ae1b1f9733da7e/sqlalchemy-2.1.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8080022e101afb17565dc5a358a165ff4a20cd97b20b4db49ebed66315b3c733", size = 4380816, upload-time = "2026-10-07T18:59:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/7aaf2b01d4d9c7168a55e0c318ab494ab436b434ebdfd4977fee5fabddf9/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:948dff080b5ac00c8e63bf9e59fa70e386cca1476f55c672a72b6ec12e5cdb05", size = 4566541, upload-time = "2026-10-07T18:37:40.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/61/3e4df04dd09d1db05ea31a2d7dc015aed26e33fefaca610eecbfe9b8d26b/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:12642e105b4e0cb2ca8428037368c1cbcded7b9d0344174607174d82b700e1eb", size = 4377842, upload-time = "2026-10-07T18:59:42.612Z" }, + { url = "https://files.pythonhosted.org/packages/53/4f/c983249adefed608b0cdc13bffe43a47a032316548e81bfdb4b6282a5b56/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976bd3fecfcfa58d69eab67e76325f564ed775aa0c0accf138ae17324b461431", size = 4621091, upload-time = "2026-10-07T18:24:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/257469b63c8cfad892b796c54a1392b3dfef93ee9273af5a8f49065d77ce/sqlalchemy-2.1.4-cp311-cp311-win32.whl", hash = "sha256:e2ace725a430e5b303fc3c422196966328ce77fb4fd053ad85572b46ed5fb71a", size = 2379577, upload-time = "2026-10-07T18:24:56.929Z" }, + { url = "https://files.pythonhosted.org/packages/3d/53/eae7fc135ac36ebc6385e87975ed5672d6311f0350f0358f38906a877f2c/sqlalchemy-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:3c998d70e60fc95e93e5971395818c50f8a34396a6352075256fefac6b5cf81b", size = 2430336, upload-time = "2026-10-07T18:24:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/81/fb/73b7ad29f65d9a114a3b42fe10ddf654bc360855dd29455381d4c7c34f97/sqlalchemy-2.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:d045e63095828d2f1fd84d499936e6791522c15c390373fc755f118e4040393a", size = 2394934, upload-time = "2026-10-07T18:22:34.762Z" }, + { url = "https://files.pythonhosted.org/packages/49/5e/cb5b078e007340661b010fa8bd31ce27468f88e09b35266544df4e0c52ca/sqlalchemy-2.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f953be9ba26039a24a5205c65d33518b608ce6f4f0f4e9b9c14eaf42a10dfc52", size = 2466469, upload-time = "2026-10-07T18:17:24.049Z" }, + { url = "https://files.pythonhosted.org/packages/b1/98/44e2fdc5bc053dae559bf4f4eb7967ceecbad162299ecfc8de2edc3fcbe7/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac64fce94c5b389062d2e3806db5dc780447591e0dfd5ead218c884f0703f2e", size = 4668213, upload-time = "2026-10-07T18:37:42.294Z" }, + { url = "https://files.pythonhosted.org/packages/08/25/ed2262f964687b06f10c2c98b2dc9c9ed211f7cc11702879969a9ac217e4/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e5045fb6aadbb0f978ab9b9d8822f7b7a97d2281814e7d13d791155664eace3", size = 4720857, upload-time = "2026-10-07T18:24:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d4/fab64c61d5d22ddbb077afd1e6b29b498bdacdf6406a03f53566e7e01686/sqlalchemy-2.1.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3a026436c51f296aa1d01243909a3b76490950e927824b10899a083cc26e7c3", size = 4369351, upload-time = "2026-10-07T18:59:45.483Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e4/33413f0fafbcf3b332320aac2c1e40f3b4f17e56359a9474cb10de4bee8b/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71040390ef01c85e9d26e5c83cb0c5942dcc8725c49186430af160ce2f54234d", size = 4591496, upload-time = "2026-10-07T18:37:44.433Z" }, + { url = "https://files.pythonhosted.org/packages/bb/65/19821440cbd5c93da053d627b3e402eff11ff252bfae37700645b3c155a4/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:07c60abaffb980b7382f2c75be8a5279c2b5df2626a0f5d751dd942799bf3b5c", size = 4362379, upload-time = "2026-10-07T18:59:48.278Z" }, + { url = "https://files.pythonhosted.org/packages/01/e3/168a0f93efd6ec40f59645a7e45ab08918e0bc8ecf07656e4ca09acdcc30/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a577e2127e52b0fe2bc54c73abb375a20ffe6f59fbc5568ccafc233f5bfcf8ef", size = 4667997, upload-time = "2026-10-07T18:24:48.72Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/0a852ef65864acd8d577d7aa6f67146167382bd6faee7a7586b9e6e28275/sqlalchemy-2.1.4-cp312-cp312-win32.whl", hash = "sha256:6c79e0c824d51c586757ecd342160bbdede9010df04bb71b9bbfffd5c7b6ee29", size = 2376664, upload-time = "2026-10-07T18:25:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/a5934263bb1d712f743289ca224ab3b87e3570ac157802291e37ab85d365/sqlalchemy-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:dffa69d2f3ba1933c1c1882dbef8fb3231b33eb19263e8b8c5cea24995071f06", size = 2429357, upload-time = "2026-10-07T18:25:02.565Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fa/a2323d81384ff214aa189057b7455b63623e66f28208b982e86c3cb042f5/sqlalchemy-2.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:e30524ae24e31d83e1b5f734862882c442f4158e3566f2c5f5e9bd3c659bb517", size = 2388756, upload-time = "2026-10-07T18:22:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e4/23174288ed2c03d6dbd5dfacd69e28303ee95f49642a8ed0544932999fb6/sqlalchemy-2.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:70006e9e6157200b795beeee04bd5cb15bccb40a14de595eb9f5dcf5945ed244", size = 2460507, upload-time = "2026-10-07T18:04:40.044Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/254fadc98bfd600445b976e81c6d777b08a728a415c3b77a8c8d35b89a83/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3341ddc430733cd961bc064889f42712a0b4056733a21c83176842aad67d12a6", size = 4594505, upload-time = "2026-10-07T18:16:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/83/6f/ac7beddc57c9c87bd77bc1c158fcbcdc20822f1873bf33ea3480d04e865f/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98f7a4bfeaed3722804f737ae2bd4077b35e57d6f4531fe612bac8160cda5acd", size = 4647364, upload-time = "2026-10-07T18:34:51.721Z" }, + { url = "https://files.pythonhosted.org/packages/0a/82/fc3891f261c4738a8b90cfdd805fe292d1af3b77f680a63b7349304c74e5/sqlalchemy-2.1.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec5d079935f67febe0ab8a3a203ad591b99508adc34ae0027f696dcb20373537", size = 4311619, upload-time = "2026-10-07T18:38:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1a/160c1320ab20e764a29721dc3fe7c31af34e291c652dca875d1ca6022b9a/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3d675b0856b6703b29d023517a4c19fecfbb55214ff5c72cd813527e40aed9b4", size = 4518031, upload-time = "2026-10-07T18:17:05.615Z" }, + { url = "https://files.pythonhosted.org/packages/30/2c/15a204333896e5dc63cb089ea20ca3ebc3c892bedf9fa00cc1a65e20d7b5/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0bb9ee6a38cb36240dc88da11888348f61506047be54de3f09496c3b0ead6f5", size = 4312818, upload-time = "2026-10-07T18:38:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/a6/55/5e78d288f198598f278b4b7baef42f18e039b14b1e1045e9df3cf571300d/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61a2c48771cf314b6613d327c795902bbc0eb6d6169deb23b35004ba6ad6cc0d", size = 4608584, upload-time = "2026-10-07T18:34:53.69Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f6/e83b93ecc6e6528623fd7aa2af27ff0660d22354b78fe6ccad03f9ecbd9f/sqlalchemy-2.1.4-cp313-cp313-win32.whl", hash = "sha256:3fd608a06bafa768ad5711df4e17eb058bdc490e9df7d39b12a90947471e8712", size = 2373880, upload-time = "2026-10-07T18:22:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/8f/46/afb02975023db6aa4b8608177c2fae17d0b435d9cbfcb5df4fa6e65a8078/sqlalchemy-2.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:b756d74527c56a7e4cfae297f7930c1d75bdf4b23f214c8c13779746d28060cb", size = 2424430, upload-time = "2026-10-07T18:22:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/21/e5/76dc82d59186b98b27589b33b01175c0d49512679276170271d9384418e2/sqlalchemy-2.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:a64d54015233f824f171009977bfbb6b08bd0347b700cf17cb047ffb94c4148f", size = 2385057, upload-time = "2026-10-07T18:11:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/43/b0/6675a01f4e6215e0a809d28a800953294ab31370fe8c4bb3eb9e28c0b5a6/sqlalchemy-2.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a2f6164c0527cd8fc4cea79a5c9d8369ffee417b8ba444a42342f36b91deb75", size = 2463645, upload-time = "2026-10-07T18:04:41.615Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/4630a4009ea08a0769d5ff6517c7fc978f6a63eba32e08c44b98c284d7e4/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6929a11ad26a91a4efd891c1252b373c2e88f056910b83ec6030ed3f2cbcb734", size = 4587911, upload-time = "2026-10-07T18:17:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/0e/02/953686f44448b92cc628245687a242799b6eb11ef30ad2bc7adacd51986d/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14528d37d7d46a92f2a483f188f7fecd86cdd789254a0412b960c9fc5e9efd6d", size = 4617539, upload-time = "2026-10-07T18:34:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/13/23/a44288ab4fa12e51c9d390e7d798d70a45669ddcbddc9dd9b5948eb1aa3f/sqlalchemy-2.1.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2cb669c6bd1f19caf51db6e3c4fdd4cbb76f9db3ef81c3aeb5e288d9bae101b", size = 4310883, upload-time = "2026-10-07T18:38:50.265Z" }, + { url = "https://files.pythonhosted.org/packages/a3/39/1c441ac015767f619a9e6cc306905bb042f94b84f2a1e930e989e9c6e209/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:63dc25b21fd9a41dc09b7aada4b3b0d97cf4b6414f74bced6ac45326bc799ac9", size = 4516511, upload-time = "2026-10-07T18:17:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b9/f54ea5ccb27d9a712d90d1617050bee761df25dc1fb5e0b7d2aa867deb51/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:308f96d24e773d64609a2a0d1161a068f9f6e9165523bc4e07aa9c45f0c4213f", size = 4312314, upload-time = "2026-10-07T18:38:53.249Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/c1e39287ee988e4c2e25c619959b8fb15b297734be040653fe85b57517ee/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93b9416b9011a3b7689a933e04ac9f61d15686b6cb1948ebc1f41467153116c3", size = 4577833, upload-time = "2026-10-07T18:34:57.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/78/5f1ae1911d2b20ccdb39ee522118533a4b5262b6e5e06bbcbb1ebd1f4617/sqlalchemy-2.1.4-cp314-cp314-win32.whl", hash = "sha256:89db94855287fdac98d74595cf13ea59fbffa608d6400ff972b0fd4c036d873f", size = 2380724, upload-time = "2026-10-07T18:22:25.374Z" }, + { url = "https://files.pythonhosted.org/packages/ca/93/4dfa4ce15d082011fb94e06e7c6b4c2957a3f0ddeb8fe9b89d007bc058d7/sqlalchemy-2.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:080f8d853aac5bb5620f0ae6f46527397cf18dce0ec2b478b478469ef3cae2c4", size = 2431689, upload-time = "2026-10-07T18:22:27.144Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c4/6f6c29eaf459c4c2d9b7d24e300bab32043f8f8a936df863f3b886b5564a/sqlalchemy-2.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:64d41be1dd88f184de1931f0173f4827122a1b49fd1150656641200c0bdf640c", size = 2396106, upload-time = "2026-10-07T18:11:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/48f851411665e394f60c669d1f9494d660f5f1fe46e275f9615cfc812a98/sqlalchemy-2.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84272f329c15081a1e09b4a7261118b4e8a547f43e00fca98e55bbdf19eff3be", size = 2503038, upload-time = "2026-10-07T18:19:41.094Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/bf83068bda4051d7fd719c14cefc15d8466ef1e3656b9f4401b0509b11e0/sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3f58bd26fc010ea28976d401845e4e6ce02e1b7c0288b3ea9c9a3c396f0bcc", size = 4919623, upload-time = "2026-10-07T18:16:45.399Z" }, + { url = "https://files.pythonhosted.org/packages/56/de/57eb70d56b70d22a9360d658b195834ecfdeff7a7bc5c2e3a7fa7a8f7823/sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82d728075d42bd457d09655cf22e99d772a648c6f67e86743a4f05b7d063ca18", size = 4830238, upload-time = "2026-10-07T18:37:04.468Z" }, + { url = "https://files.pythonhosted.org/packages/70/3d/c410e9e79a53fff4c04444da609fed6404868d250f11fe8bc53d827bfb0e/sqlalchemy-2.1.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0970394ec5d9e397aafc5bc5fa2b7f8b58cb191f2703006b19a96ef4bf00b8d9", size = 4508380, upload-time = "2026-10-07T18:38:44.277Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c3/01b93821ba35b5b162e79c613279d960a120767694f656da1c1374dd3ed3/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6005f2f5fcd67fdd721446128e6a2a1d18f77387a604fbd26b0006a086b33096", size = 4775311, upload-time = "2026-10-07T18:16:47.724Z" }, + { url = "https://files.pythonhosted.org/packages/c7/88/0b40754e4d851d33548792062c23467a3d8dc07f2eff90cb19e4c404fb4c/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0e01a3e199ae219381c4889993c5584b1b905fffe6830f639adb6770036a8913", size = 4508558, upload-time = "2026-10-07T18:38:47.857Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2f/3916954eca5596d9e93fccd2ec0e45fd8c65981debac0ec4617639ded6ba/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22129e7d00ac66b291840c4dc83a9c497456ab5bffa682dcbfdc2356f9e49e5a", size = 4767114, upload-time = "2026-10-07T18:37:06.792Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d6/6a29716aec6ae17cd77e27b5e0dedc68cf9068594f2b601806c1d146427a/sqlalchemy-2.1.4-cp314-cp314t-win32.whl", hash = "sha256:bc33d3e59d4e84b8866cc9ba13732585e37212dbe3542cb09f232682b36f47a5", size = 2439584, upload-time = "2026-10-07T18:22:44.434Z" }, + { url = "https://files.pythonhosted.org/packages/34/79/2f0b33647d2d26f098269096c1864c0b4e81095354cdedb95192647f47cd/sqlalchemy-2.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:346d144e8912ae087b10d3c2081657cb634728600693eee6dbb71d7eb4768101", size = 2507217, upload-time = "2026-10-07T18:22:46.176Z" }, + { url = "https://files.pythonhosted.org/packages/93/e5/869c1ac0a21e17e4617b6a7828b50320bedb7074b6d67aec59299be5cdba/sqlalchemy-2.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3e5de57c71b3460e2ca6137e82cd3cb8c9f711f301f50d5c77156fdb9c822999", size = 2425329, upload-time = "2026-10-07T18:12:20.595Z" }, + { url = "https://files.pythonhosted.org/packages/2b/8e/a082a165b473dae45d2f2f79be15f5c405ac579830c64253efbf04695177/sqlalchemy-2.1.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:418786f05387ddb66ee683a1d016c5a8d9bf7be921e6ee8f285c7b6ac961a731", size = 2463572, upload-time = "2026-10-07T18:11:12.053Z" }, + { url = "https://files.pythonhosted.org/packages/d1/35/74db254005ecb384533973b157ba1fc3fe5bc41a5bc6e0500ab8369c49e6/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:283914efed30e4d44301e36ac90ad048570538b8a70f072fe01578d9b205d09c", size = 4591740, upload-time = "2026-10-07T18:01:00.314Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/5cadd72b0c26b6ee7c1e6950cb9f0cfc383246a842314a1b2a87f455db25/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2eacdbeb990b80235763860923c60a8393745b66f7149a734980c65896da72", size = 4627040, upload-time = "2026-10-07T18:09:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/8e/78/aed93cc373f61b57625e1f9f84bbf12358e32e935e64fa098f3a446e1203/sqlalchemy-2.1.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e43fca5fdd5f34a3f8c54107a3648d3139de8bbf596a189f3f0de94bd84949bb", size = 4333103, upload-time = "2026-10-07T18:33:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/ecc6bbd365671cdc512a59d42afa7c34b2833a8d841754918ae3f62d36dd/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2e1b5343d315b10a4a71da481729f66f830a561595e02b61e8a5a65d658325ac", size = 4519962, upload-time = "2026-10-07T18:01:02.268Z" }, + { url = "https://files.pythonhosted.org/packages/58/58/9f8f6157c2252aefe73f4a0b3859413bb720d14321aa7f367c691949aaf8/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:42c37c06adcecf444e8c981f7e9237a41bdd445c83da0df9e08b4ad958becbbc", size = 4331935, upload-time = "2026-10-07T18:33:50.334Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/a4ae4b95d17607004f01e9a085fb221087c557bbad77a3d87d5d0a5fd8bc/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:bab7f51d38766d6a64da2b41976f1b3f9cc2ff37d3f2f63bdbac876199f3a48e", size = 4589275, upload-time = "2026-10-07T18:09:26.872Z" }, + { url = "https://files.pythonhosted.org/packages/65/27/56f69293a01279ac0e6077b8c358eb0f1c2afc6aa17428414a86c8871042/sqlalchemy-2.1.4-cp315-cp315-win32.whl", hash = "sha256:1541ba5bf0f232cd61f9ef3df78c93977c72ba6031506a0e6d057b2a3ddb76e9", size = 2380494, upload-time = "2026-10-07T18:04:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7c/ff7e29f95996ed49b950afd531b89e7c8d15addb41735643d07090550090/sqlalchemy-2.1.4-cp315-cp315-win_amd64.whl", hash = "sha256:596a95611c217cb19c21f02f43c637cb507cab71dcf0467c5c7d98fcdd703007", size = 2431760, upload-time = "2026-10-07T18:04:27.275Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/4eaa4978760cd632093ea272e7c4f88223619202f5481f897e67d4377409/sqlalchemy-2.1.4-cp315-cp315-win_arm64.whl", hash = "sha256:0d1ca95e42ce3c18818f170b741d30a33b292c6f6b9a202ffd717e28fc99b8c7", size = 2395807, upload-time = "2026-10-07T18:30:54.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/b806fbfc61ade37c4f3aecec0874c345fb297b56a3743116dcefa3e4700d/sqlalchemy-2.1.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0f672ed6972164fec94a8f0b21dcf8545080d0727866335fb8adf9f4764ce6ec", size = 2499195, upload-time = "2026-10-07T18:19:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/4f9fba8340222f09287e936d7b76e6911a4e507c7d6373ada770e8f697d5/sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72e3fa41d1fdab87d4e88bbdd69c9522e2795549fbe7b07bcf4ae9ec175f4b11", size = 4896914, upload-time = "2026-10-07T18:16:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/55/34/c4aeec7bee453badd8b0e02c2021a13bd70ef01038303d05326e99f595b6/sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb2cb98d056e63e353ed697750004e07c79b054d73059ba3184ca3bb07296bea", size = 4846593, upload-time = "2026-10-07T18:37:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/6dd8504364e5f5efd328e98fea963e5a2e978ff8dcba70d95231314f82a9/sqlalchemy-2.1.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d66fdcc5506e0f8bb8d3f4f95125220a7cd6c46e8b1762750f01e9639973dd8", size = 4494821, upload-time = "2026-10-07T18:38:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/df/42/dc584c098bce29578fd0611cd6f36830e06b4dd2505d3020a0b592f4cf08/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:81f802c96dbf96e59c6982fa1b87da7868920fb0c27b9b81e560a62f57c2ccfb", size = 4752545, upload-time = "2026-10-07T18:16:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/8c/41/69a70c1419bea97e80f65ce09f4f626df464752b276f4f3d69ff6fbf2325/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:acf8982c70471a68aa90d1aba08b48860c55b3357ec84ccb0f09368ead2ce099", size = 4494093, upload-time = "2026-10-07T18:38:54.37Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bd/d296c2223e8417b350db215d94dcd344bc0dfe9deb7d810a21f7d8cd0b14/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:778094c83e36c430756a7e1a1ac66fc3cffb2c6a1067958fe6b920abcec7bc5a", size = 4771973, upload-time = "2026-10-07T18:37:10.93Z" }, + { url = "https://files.pythonhosted.org/packages/13/4c/c3a10d9da10e4e60808ffd1825547b383c0d7ca9e56d15cdae47c04e752e/sqlalchemy-2.1.4-cp315-cp315t-win32.whl", hash = "sha256:963348422b22f760e9462e56bc32bf4d95d224cc5b8c79a3c6e3b786d3d2a2b2", size = 2436383, upload-time = "2026-10-07T18:22:48.162Z" }, + { url = "https://files.pythonhosted.org/packages/51/de/8045d4ad1fd3a66c3b9bb576f3734c86015e19ae2f1617af92eb63cf9e58/sqlalchemy-2.1.4-cp315-cp315t-win_amd64.whl", hash = "sha256:fba3500e170d25f581e053009edeb0b158116084d91d465de218718d336b67c3", size = 2502237, upload-time = "2026-10-07T18:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4b/245e2315d331cc15765a2373e068445fbd28eb63beb23ea862828808c0bf/sqlalchemy-2.1.4-cp315-cp315t-win_arm64.whl", hash = "sha256:0a9a464bc360856b7ea9bf8aa26aab92ca115dd08149cb0e004063d5db13584b", size = 2420917, upload-time = "2026-10-07T18:12:21.876Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/dbf11a262f6fbb41390cab2d8e47a30ec0961018b68201607b599dd489f5/sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7", size = 2054935, upload-time = "2026-10-07T18:01:16.403Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "websockets" +version = "17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f7/8a90cc2abbe4709dff4450824beb07cbf7256566ee043c2ba3faa1d5fb2a/websockets-17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:569ed5db651e420b13279f9333443bb5b84a436cc66b599cbc535697ae4434a0", size = 217725, upload-time = "2026-10-03T14:52:50.797Z" }, + { url = "https://files.pythonhosted.org/packages/7f/85/e418ba2e7e412a5b35c42caf6d4fcc8ecee1a66edc4f2a5f780da775aa77/websockets-17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3892d76754b5f36fb40619f3ef09c68e5c3091f1ab8840964518ae5a41f30952", size = 215415, upload-time = "2026-10-03T14:52:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/e4d7eb2e2e4ffed0b0dfbd2d1aa3c8101f42d34ac9f58b47b822c565d1d4/websockets-17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5436ffea003adb50e283ca0684a3fcaa1396104f841736c3322ee6582bd09e98", size = 215690, upload-time = "2026-10-03T14:52:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/4b/dd/e8718fa6114c4cd15b05133b548af985638e80774253c1faee8d49874c38/websockets-17.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9df9d048def11365d170b375b6ffc8b23a7f188c3560acd4418ba088ca2e2705", size = 224756, upload-time = "2026-10-03T14:52:56.132Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d5161c46f3eee2ae67cdec489532b51695a1c27ccfadd858dcd419ea26ac/websockets-17.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:376a693697ddb695ea282ead76060f4847f90e564b12b4389f2c7589e6fadb9e", size = 225026, upload-time = "2026-10-03T14:52:57.671Z" }, + { url = "https://files.pythonhosted.org/packages/d5/9a/3f83bace9636af07d7bb00cbae0bcb5bd1697892babac79664f3a2b3a011/websockets-17.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd63d0c7ed0d3d719c91b5a3861f0f0b3cec9bf223033ddf69d17aaac74bb6d", size = 226260, upload-time = "2026-10-03T14:52:59.114Z" }, + { url = "https://files.pythonhosted.org/packages/03/50/5347cb13f97430526b9c31e9b30fa639bb1d0f9d53074da8622b327cfb6f/websockets-17.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:48997ed4431d8006988788ef4b62e1fd3f053c7463b4fa793aa6c4f9e96a3bb7", size = 229573, upload-time = "2026-10-03T14:53:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/14/2b/7511082e3fe0cc3233ecb0c3b019ef12c1cd9df60ac1a7858f6093f490b5/websockets-17.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e312e07557a5ad348f4e83d3419773527f6e790c7f97928b1911d767b6ea1c7", size = 226819, upload-time = "2026-10-03T14:53:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/4f/86c1a9db323d4fdbf56cc089942f18328a48c3efbbad0d625a66a2195842/websockets-17.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:902ce8cafca2dc14cef9558a6fc3b45dbf7f121d1404bf2ad18a1c894555e48c", size = 225595, upload-time = "2026-10-03T14:53:03.768Z" }, + { url = "https://files.pythonhosted.org/packages/81/92/4f54f6031d97e284e01a0728cef38b095478dcaab81837aac8cb0e26ea6a/websockets-17.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e53d950e16d4bb672a5ff41fe3131e65a4e5d688d694e1c7074c8c9990bb3ceb", size = 222875, upload-time = "2026-10-03T14:53:05.7Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/c6d59b8b45c730a56ee5acf6c0ce9896356cba25ef3f9a4c9d1796f2e44f/websockets-17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:946ac2164d646e733004946ae39536b5af473853183d81da5962e29d36e3ad35", size = 225745, upload-time = "2026-10-03T14:53:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7c/5d9b91b43aa339b96551630940a847270c10a9d70243be4c81fe5dc6fb34/websockets-17.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:660aa158127035e741d4b1835dbe79ae18a1fbb21ecd236655f31d60110e68d5", size = 224342, upload-time = "2026-10-03T14:53:08.893Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/c90c24b0dfb12b8b6f0d5e13fc7cf9f121a2e072f7f54bb888da826b2012/websockets-17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4733fc2d99fe888261417b7e29995403a72d9ffa78629902882325ea141177f2", size = 225109, upload-time = "2026-10-03T14:53:10.495Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5b/f38ca1299c10ea1cfc7f1d129c65a15e4f4b281d1f3dc25891d5fb9bf9db/websockets-17.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c2ec7e51157a3fa0e9cfdb1a8969bab38d1c22ad1ace7c6cea006383b43a1ad4", size = 226151, upload-time = "2026-10-03T14:53:11.976Z" }, + { url = "https://files.pythonhosted.org/packages/f9/21/ff6089c6921c7ae0e1801a4948aa1a3831deb1596e8f0d1cd3a0c0e44109/websockets-17.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ada04d0262ab06527054a2a497f384d102698ff39b3865dc566a7d24b6f4058c", size = 223732, upload-time = "2026-10-03T14:53:13.864Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c4/01ca4212f665e351123c84e7f7156badf5da958ef8aad8781b538682c699/websockets-17.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c393a202df08e96ed619310f0cd78be700e532a57d9a6ceee5f80b4e35bef14", size = 224764, upload-time = "2026-10-03T14:53:15.411Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/bc17b39d1e62b771d8a417b714439252d7abfca21185242cc293d75b20d5/websockets-17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:af4c565b923bb5975401b8e4cedc2e17b2fdbf33b905737ee12384e6a6fd9507", size = 225002, upload-time = "2026-10-03T14:53:16.93Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f6/ccab831ab6a841a35134937a1794c0f3f09ccc604625505be061dec5b3e4/websockets-17.2-cp311-cp311-win32.whl", hash = "sha256:c81d6cdbacccda7e0eef3b076a457fd14c3835cdbc5993d2881580c2fb1f5f26", size = 218226, upload-time = "2026-10-03T14:53:18.376Z" }, + { url = "https://files.pythonhosted.org/packages/0a/18/4fcc23f2159393ad7a668574ee97ee5a135003bfcbdd56b30581110c0fe8/websockets-17.2-cp311-cp311-win_amd64.whl", hash = "sha256:55c5b9eab079540bfb639b40b07b7b467e5c5a7ecf97a65cc8665781381c9856", size = 218523, upload-time = "2026-10-03T14:53:19.947Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/5a3f4f75dadb7fbf980ea4b59d02528f87fb2d3c0ac120c2ff50d1dc1b34/websockets-17.2-cp311-cp311-win_arm64.whl", hash = "sha256:55f9a808a0e072473337c240c939849818276e288e2374b832255b5b791b0851", size = 218454, upload-time = "2026-10-03T14:53:21.417Z" }, + { url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" }, + { url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" }, + { url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" }, + { url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" }, + { url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" }, + { url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" }, + { url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" }, + { url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" }, + { url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" }, + { url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" }, + { url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" }, + { url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" }, + { url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" }, + { url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" }, + { url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" }, + { url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" }, + { url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" }, + { url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" }, + { url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" }, + { url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" }, + { url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" }, + { url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" }, + { url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" }, + { url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" }, + { url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e2/09ad9cec0fc7e39f983b52f9e49c44f89b7cf7a61d4761fa7fc398f003f9/websockets-17.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2de1ccf298f5c9e0f27113836d742edb95f015eee3148f004ac386f7ba9a05b1", size = 215348, upload-time = "2026-10-03T14:56:41.037Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/c307b5d8cdf1852d00606a0403502f0ca5cd8a4736550bab70abce09f7e9/websockets-17.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:761cde41439f0be761aa460e1451a31e2e14baf4a46db6fe4913e5a06a90df66", size = 215621, upload-time = "2026-10-03T14:56:43.097Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/af8412f154cd0568afc043ab478cc8c1ebdf9337b25c85cb9a049d18cfcb/websockets-17.2-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:15a7101b660a9f15fac34108c92cefc9848f6753a50acef8869e3cd94148fdb7", size = 216569, upload-time = "2026-10-03T14:56:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/fc/76/92ae57b985378036bb8133ea39d1e5cc4d97accad9cae38169426bdcef75/websockets-17.2-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:214da56dba368f61b3d745c77630b2d03c61c02da7b42fe80ef6efba079d3077", size = 216462, upload-time = "2026-10-03T14:56:46.771Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/e3b276473f7f38984990eb29cf525ffaed131f6136bedb929b5c2ce7151e/websockets-17.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80cbc645af23ac5c12096545c161626960114a1bc10f864760558d3b3e82ba18", size = 217355, upload-time = "2026-10-03T14:56:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a1/459ab96c5cda8a2164f594be6dc9f868de7971e6abafa696ea07534139a6/websockets-17.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:063508ce9e0db745f30ab52fc652f4e59efc79c2b74934b3837d5cdb974da620", size = 218612, upload-time = "2026-10-03T14:56:50.287Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] diff --git a/dev_tools/services/acknowledg-forge/gitlab/.gitignore b/dev_tools/services/acknowledg-forge/gitlab/.gitignore new file mode 100644 index 0000000000..add0d12103 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/.gitignore @@ -0,0 +1,8 @@ +.venv/ +app-venv/ +build/ +dist/ + +*.egg-info/ + +__pycache__/ diff --git a/dev_tools/services/acknowledg-forge/gitlab/acknowledg_forge_gitlab/__init__.py b/dev_tools/services/acknowledg-forge/gitlab/acknowledg_forge_gitlab/__init__.py new file mode 100644 index 0000000000..ddeffa4e50 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/acknowledg_forge_gitlab/__init__.py @@ -0,0 +1,320 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''acknowledg-forge for GitLab (gitlab.com or self-managed). + +Implements the ``edgacknowledg.forge`` protocols over the GitLab REST API +(v4): merge requests, pipelines and job artifacts, merge request notes, and +merge request / pipeline webhooks. + +GitLab specifics: + +- Repositories are project paths ('group/project'). +- CI artifacts belong to jobs, so an artifact name (e.g. + ``ACKNOWLEDG_FORGE_TEST_ARTIFACT``) names the *job* whose artifacts + archive holds the results. +- ``ACKNOWLEDG_FORGE_CI_WORKFLOW`` names the pipeline to track (its + ``workflow:name``), or '*' for the newest pipeline of any name. +''' + +import hmac +import json +import urllib.parse + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Optional, Sequence + +import httpx + +from edgacknowledg.forge import ( + ArtifactInfo, + ArtifactTooLargeError, + CIConclusion, + CIRunInfo, + CIRunTouched, + CIStatus, + ForgeError, + ForgeEvent, + PullRequestInfo, + PullRequestState, + PullRequestTouched, +) +from edgacknowledg.forge.http import ForgeHttpClient +from edgacknowledg.forge.workspace import basic_auth_header + +DEFAULT_API_URL = 'https://gitlab.com/api/v4' + +ANY_PIPELINE = '*' + +_COMPLETED = { + 'success': CIConclusion.SUCCESS, + 'failed': CIConclusion.FAILURE, + 'canceled': CIConclusion.CANCELLED, + 'skipped': CIConclusion.OTHER, +} + + +def _parse_time(value: str) -> datetime: + return datetime.fromisoformat(value) + + +def _project(repository: str) -> str: + '''The API path segment for a project path.''' + return urllib.parse.quote(repository, safe = '') + + +def web_url_for(api_url: str) -> str: + return api_url.rstrip('/').removesuffix('/api/v4') + + +class GitLabClient(ForgeHttpClient): + '''Authenticated GitLab REST access.''' + + def __init__(self, + *, + api_url: str = DEFAULT_API_URL, + token: Optional[str], + transport: Optional[httpx.AsyncBaseTransport] = None) -> None: + headers = {'PRIVATE-TOKEN': token} if token else {} + super().__init__(base_url = api_url, headers = headers, + transport = transport) + self.token = token + + +class GitLabForge: + '''``Forge`` over the GitLab REST API.''' + + name = 'gitlab' + + def __init__(self, + client: GitLabClient, + *, + pipeline: Optional[str], + artifact_jobs: Sequence[str], + clock: Optional[Callable[[], datetime]] = None) -> None: + self._client = client + self._pipeline = pipeline + # Only these jobs' artifacts are reported. + self._artifact_jobs = frozenset(artifact_jobs) + self._web_url = web_url_for(client.base_url) + self._clock = clock or (lambda: datetime.now(timezone.utc)) + + def clone_url(self, repository: str) -> str: + return f"{self._web_url}/{repository}.git" + + def git_http_headers(self) -> Sequence[str]: + if not self._client.token: + return [] + return [basic_auth_header('oauth2', self._client.token)] + + def _merge_request(self, repository: str, payload: Any) -> PullRequestInfo: + state = { + 'opened': PullRequestState.OPEN, + 'merged': PullRequestState.MERGED, + }.get(str(payload['state']), PullRequestState.CLOSED) + iid = int(payload['iid']) + return PullRequestInfo( + repository = repository, + number = iid, + state = state, + title = str(payload['title']), + author = str((payload.get('author') or {}).get('username', '')), + url = str(payload['web_url']), + base_ref = str(payload['target_branch']), + # Informational only (the service computes the merge-base itself); + # list results don't include diff_refs. + base_sha = str((payload.get('diff_refs') or {}).get('base_sha') or ''), + head_sha = str(payload['sha']), + fetch_ref = f"refs/merge-requests/{iid}/head", + updated_at = _parse_time(payload['updated_at']), + ) + + async def list_open_pull_requests(self, + repository: str) -> list[PullRequestInfo]: + payloads = await self._client.paginate( + f"/projects/{_project(repository)}/merge_requests", + {'state': 'opened'} + ) + return [self._merge_request(repository, payload) for payload in payloads] + + async def get_pull_request(self, + repository: str, + number: int) -> PullRequestInfo: + payload = await self._client.get_json( + f"/projects/{_project(repository)}/merge_requests/{number}" + ) + return self._merge_request(repository, payload) + + async def latest_ci_run(self, + pull_request: PullRequestInfo + ) -> Optional[CIRunInfo]: + if self._pipeline is None: + return None + project = _project(pull_request.repository) + params: dict[str, Any] = { + 'sha': pull_request.head_sha, + 'order_by': 'id', + 'sort': 'desc', + 'per_page': 1, + } + if self._pipeline != ANY_PIPELINE: + params['name'] = self._pipeline + pipelines = await self._client.get_json( + f"/projects/{project}/pipelines", + params + ) + if len(pipelines) == 0: + return None + pipeline = pipelines[0] + + status_name = str(pipeline['status']) + artifacts: tuple[ArtifactInfo, ...] = () + if status_name in _COMPLETED: + status = CIStatus.COMPLETED + conclusion: Optional[CIConclusion] = _COMPLETED[status_name] + artifacts = await self._job_artifacts(project, pipeline['id']) + else: + status = ( + CIStatus.RUNNING if status_name == 'running' else CIStatus.PENDING + ) + conclusion = None + return CIRunInfo( + run_id = str(pipeline['id']), + # Retried jobs get new ids (and so new artifact ids); the pipeline + # itself has no attempt number. + attempt = 1, + head_sha = str(pipeline['sha']), + status = status, + conclusion = conclusion, + completed_at = ( + _parse_time(pipeline['updated_at']) if status == CIStatus.COMPLETED + else None + ), + url = str(pipeline['web_url']), + artifacts = artifacts, + ) + + async def _job_artifacts(self, + project: str, + pipeline_id: object + ) -> tuple[ArtifactInfo, ...]: + now = self._clock() + artifacts = [] + for job in await self._client.paginate( + f"/projects/{project}/pipelines/{pipeline_id}/jobs"): + name = str(job['name']) + if name not in self._artifact_jobs: + continue + archives = [ + item for item in job.get('artifacts') or [] + if item.get('file_type') == 'archive' + ] + if len(archives) == 0: + continue + expire_at = job.get('artifacts_expire_at') + artifacts.append(ArtifactInfo( + name = name, + id = str(job['id']), + size = sum(int(item.get('size') or 0) for item in archives), + digest = None, + expired = ( + expire_at is not None and _parse_time(expire_at) <= now + ), + )) + return tuple(artifacts) + + async def download_artifact(self, + repository: str, + artifact: ArtifactInfo, + destination: Path, + *, + max_bytes: int) -> None: + if artifact.size > max_bytes: + raise ArtifactTooLargeError( + f"artifact {artifact.name} is {artifact.size} bytes" + ) + await self._client.download( + f"/projects/{_project(repository)}/jobs/{artifact.id}/artifacts", + destination, + max_bytes = max_bytes + ) + + async def aclose(self) -> None: + await self._client.aclose() + + +class GitLabWebhooks: + '''``ForgeWebhooks`` for GitLab's secret-token deliveries.''' + + def __init__(self, secret: str) -> None: + self._secret = secret.encode() + + def verify(self, headers: Mapping[str, str], body: bytes) -> bool: + token = headers.get('x-gitlab-token', '').encode() + return hmac.compare_digest(token, self._secret) + + def parse(self, + headers: Mapping[str, str], + body: bytes) -> list[ForgeEvent]: + event = headers.get('x-gitlab-event') + if event not in ('Merge Request Hook', 'Pipeline Hook'): + return [] + payload = json.loads(body) + repository = str(payload['project']['path_with_namespace']) + attributes = payload.get('object_attributes') or {} + if event == 'Merge Request Hook': + return [PullRequestTouched(repository, int(attributes['iid']))] + events: list[ForgeEvent] = [ + CIRunTouched(repository, str(attributes['sha'])) + ] + merge_request = payload.get('merge_request') + if merge_request: + events.append(PullRequestTouched(repository, int(merge_request['iid']))) + return events + + +class GitLabCommenter: + '''``ForgeCommenter`` posting merge request notes as the token's user.''' + + def __init__(self, client: GitLabClient) -> None: + self._client = client + self._username: Optional[str] = None + + async def post_comment(self, + repository: str, + number: int, + body: str) -> str: + response = await self._client.request( + 'POST', + f"/projects/{_project(repository)}/merge_requests/{number}/notes", + json_body = {'body': body} + ) + return str(response.json()['id']) + + async def _own_username(self) -> Optional[str]: + if self._username is None: + try: + self._username = str( + (await self._client.get_json('/user'))['username'] + ) + except ForgeError: + self._username = '' + return self._username or None + + async def find_comment(self, + repository: str, + number: int, + marker: str) -> Optional[str]: + username = await self._own_username() + for note in await self._client.paginate( + f"/projects/{_project(repository)}/merge_requests/{number}/notes", + {'sort': 'asc'}): + author = (note.get('author') or {}).get('username') + if username is not None and author != username: + continue + if marker in str(note.get('body', '')): + return str(note['id']) + return None diff --git a/dev_tools/services/acknowledg-forge/gitlab/acknowledg_forge_gitlab/__main__.py b/dev_tools/services/acknowledg-forge/gitlab/acknowledg_forge_gitlab/__main__.py new file mode 100644 index 0000000000..1def62cb95 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/acknowledg_forge_gitlab/__main__.py @@ -0,0 +1,44 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Run acknowledg-forge against GitLab (configuration: see README.md).''' + +from edgacknowledg.forge import service +from edgacknowledg.forge.config import ForgeServiceConfig + +from acknowledg_forge_gitlab import ( + DEFAULT_API_URL, + GitLabClient, + GitLabCommenter, + GitLabForge, + GitLabWebhooks, +) + + +def build(config: ForgeServiceConfig) -> service.ForgeComponents: + client = GitLabClient( + api_url = config.api_url or DEFAULT_API_URL, + token = config.forge_token + ) + return service.ForgeComponents( + forge = GitLabForge( + client, + pipeline = config.ci_workflow, + artifact_jobs = (config.test_artifact, config.bench_artifact) + ), + webhooks = ( + GitLabWebhooks(config.webhook_secret) if config.webhook_secret + else None + ), + commenter = GitLabCommenter(client) if config.forge_token else None, + ) + + +def main() -> None: + service.main(build) + + +if __name__ == '__main__': + main() diff --git a/dev_tools/services/acknowledg-forge/gitlab/build.sh b/dev_tools/services/acknowledg-forge/gitlab/build.sh new file mode 100755 index 0000000000..c09a5c80a4 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/build.sh @@ -0,0 +1,8 @@ +#!/usr/bin/bash + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +uv build diff --git a/dev_tools/services/acknowledg-forge/gitlab/edgacknowledg b/dev_tools/services/acknowledg-forge/gitlab/edgacknowledg new file mode 120000 index 0000000000..452c595d86 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/edgacknowledg @@ -0,0 +1 @@ +../../../pylibs/edgacknowledg \ No newline at end of file diff --git a/dev_tools/services/acknowledg-forge/gitlab/edgtest b/dev_tools/services/acknowledg-forge/gitlab/edgtest new file mode 120000 index 0000000000..24d14b80da --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/edgtest @@ -0,0 +1 @@ +../../../pylibs/edgtest \ No newline at end of file diff --git a/dev_tools/services/acknowledg-forge/gitlab/edgutil b/dev_tools/services/acknowledg-forge/gitlab/edgutil new file mode 120000 index 0000000000..c8493c7e4d --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/edgutil @@ -0,0 +1 @@ +../../../pylibs/edgutil \ No newline at end of file diff --git a/dev_tools/services/acknowledg-forge/gitlab/pyproject.toml b/dev_tools/services/acknowledg-forge/gitlab/pyproject.toml new file mode 100644 index 0000000000..88b5ddfc3e --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "edg-acknowledg-forge-gitlab" +version = "2026.10.15.0" +requires-python = ">=3.11" +dependencies = [ + "aiosqlite", + "alembic", + "httpx", + "hypercorn", + "quart", + "sqlalchemy[asyncio]>=2.0", + "websockets", +] + +[project.optional-dependencies] +postgresql = ["asyncpg"] + +[project.scripts] +acknowledg-forge-gitlab = "acknowledg_forge_gitlab.__main__:main" + +[dependency-groups] +dev = ["pytest", "pytest-asyncio"] + +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = [ + "acknowledg_forge_gitlab*", + "edgacknowledg*", + "edgtest*", + "edgutil*", +] + +[tool.setuptools.package-data] +"edgacknowledg.forge.db" = ["migrations/script.py.mako"] diff --git a/dev_tools/services/acknowledg-forge/gitlab/run-service.sh b/dev_tools/services/acknowledg-forge/gitlab/run-service.sh new file mode 100755 index 0000000000..44ae14aeca --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/run-service.sh @@ -0,0 +1,12 @@ +#!/usr/bin/bash + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# Run acknowledg-forge-gitlab for local development. Configuration comes +# from the environment (see ../README.md); at least ACKNOWLEDG_FORGE_HOME and +# ACKNOWLEDG_FORGE_REPOSITORIES must be set. + +exec uv run acknowledg-forge-gitlab "$@" diff --git a/dev_tools/services/acknowledg-forge/gitlab/setup-venv.sh b/dev_tools/services/acknowledg-forge/gitlab/setup-venv.sh new file mode 100755 index 0000000000..7bc514267b --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/setup-venv.sh @@ -0,0 +1,8 @@ +#!/usr/bin/bash + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +uv sync diff --git a/dev_tools/services/acknowledg-forge/gitlab/uv.lock b/dev_tools/services/acknowledg-forge/gitlab/uv.lock new file mode 100644 index 0000000000..d1950d46c0 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/gitlab/uv.lock @@ -0,0 +1,946 @@ +version = 1 +revision = 5 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/4e/59dc964f962f09e3ed472e5d2d3ba670a41a2be25080dc62ab3db507ff5e/asyncpg-0.32.0.tar.gz", hash = "sha256:45e64e56714d888330b884aad1dfb363d0bf43fb343e3d1a8968525f3bade478", size = 1075156, upload-time = "2026-10-06T20:32:40.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/27/1a7970f1ece6c205b03c79f45b89420dee9655ffb66bd2c11be8f40c248a/asyncpg-0.32.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5789340b9bcdab94a19eb8ff119322a09991e3626d131b55828535b373e285d4", size = 686071, upload-time = "2026-10-06T20:30:39.115Z" }, + { url = "https://files.pythonhosted.org/packages/2b/47/085934d0290806a92789eee860109c44bea71ff8bc7850a9d3a30da7a819/asyncpg-0.32.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:057ed2455e4e14ad9949f1ac1829112c7d0454c9810b124f36de1486febe6824", size = 692193, upload-time = "2026-10-06T20:30:40.563Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2c/d92524b9e860aecd119c0ebe43f3b9eca26dc2b75c4dfe1be3e999e3f6b1/asyncpg-0.32.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c938c4da9166ac1ef330475e314e2b94c68bde2795be0f4e8a1e00ccd806cadd", size = 3196713, upload-time = "2026-10-06T20:30:42.123Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/3ac7cb86aa287e5bbceaeb783ee6e4f51cd2a001f1747ef4f1236a20bde6/asyncpg-0.32.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:968c570c5913b7ce0995953d7239bd2367142d1af4359f87699f7a6ca75c4382", size = 3260618, upload-time = "2026-10-06T20:30:43.552Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/618ac36b2970b437d45523f50b5580dba0c34756bbf2153306f82a2697e5/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96c8226d2026e025852facb5a05035ea5e11b14bebb6b42e4e43948ef8f0d075", size = 3132973, upload-time = "2026-10-06T20:30:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/54db41b3d5fe26b0401a49327ffce439195c5f6073d8afbbdc9758cb35c3/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d3f745f4947df9004e2637753ff81d52f305f790f49d67f72e1677db12b07a7b", size = 3251612, upload-time = "2026-10-06T20:30:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e0/ed1e7536ce949896de29ee955b473659b3daa7887e7081030dba2b15ea5d/asyncpg-0.32.0-cp311-cp311-win32.whl", hash = "sha256:469e6520a839957304582eb8a708d874985914500b64517155f80e6fec00e742", size = 538739, upload-time = "2026-10-06T20:30:48.355Z" }, + { url = "https://files.pythonhosted.org/packages/df/eb/52c4bddad17ff1bee485ae83e08c752a998ef04ac5df76f03fef6430d0ed/asyncpg-0.32.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a1e671e67f4b0bef3c03f37a896d61706f769a83922c119070f1f04e415dc17", size = 610534, upload-time = "2026-10-06T20:30:50.003Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/9af12f2b3300c425a151ef8f85f47c0db76135827c549031858954805ff7/asyncpg-0.32.0-cp311-cp311-win_arm64.whl", hash = "sha256:901bc87b94539f32853bd73a9b02fa78f7feed4cf628824caad3093ec6662f58", size = 574363, upload-time = "2026-10-06T20:30:51.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/06/d5f956db9c936c90cd3289cf948a86c3efc9849e26354356c23da29f6a2d/asyncpg-0.32.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7cb31f7a8472ddc6b6f5c9da1290e901d5c77c8441c7213bd13b13ef6fe6359c", size = 681566, upload-time = "2026-10-06T20:30:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/ea55f3b26fd40ec90e5b6d6c53b9ff52633cf6b87a468d9c033a727832f4/asyncpg-0.32.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:643d8d6e955a355045dddfe827d74f4f0d1dc4a18e06963a08260af838fbf093", size = 704359, upload-time = "2026-10-06T20:30:54.608Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/a3704e8675d37b168f3584661fc9f64f3021659c9b94e51cf9ab957b2bc5/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:14ff79ca2574182ce258159c48978a086f9026fc121d935017b5d10c64fa3c72", size = 3707008, upload-time = "2026-10-06T20:30:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/30/30/4fd8d1155b3d7a32a2c241dcb9c5d9e9bd74a59ae71ed25ef8ddb8e038e1/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:54851411bee2aa51a30d0911524201fbb05f82cc0f7c248b140203db637c723d", size = 3810163, upload-time = "2026-10-06T20:30:58.114Z" }, + { url = "https://files.pythonhosted.org/packages/c1/25/5b0992d45661e1488aba775cf17a2e6c82c7d1d7e10acc71efd394760a00/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8592f0ed9c315b2117dbdc707cf3292f09a89d5b07661016a84dd881326965cf", size = 3600446, upload-time = "2026-10-06T20:30:59.946Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/1c82c6feacec813423401b5aef1a43baea951694157f4d405b2d14e80e6d/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4dbe0982cb3ded878de0867dfaeae3116faf471d484ea28b3e3da942f01fb778", size = 3764563, upload-time = "2026-10-06T20:31:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/84/f5/5a3796088f0c3f7d22aaf7c48536f40b27e44b7c9603d4d7abfeca2ed97e/asyncpg-0.32.0-cp312-cp312-win32.whl", hash = "sha256:fbe1f8c788fb5df18ea8a5432dfa2473fd8f7f088025fb83d089a7c7b37e37b0", size = 551810, upload-time = "2026-10-06T20:31:03.248Z" }, + { url = "https://files.pythonhosted.org/packages/af/42/f4d333a3f67b0e7cf58ea855f9d5d9104ce38c21f2a2f22bf7dce524428c/asyncpg-0.32.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd7157a86817730c3239bc687abf8186a471525d695e225c187b9a523a808a98", size = 626763, upload-time = "2026-10-06T20:31:04.927Z" }, + { url = "https://files.pythonhosted.org/packages/a8/82/9d82e16e1d0b4e2a639a2db649d4b444b8a479cd52553a9c36ba0d6320a8/asyncpg-0.32.0-cp312-cp312-win_arm64.whl", hash = "sha256:9509e21fc526f1fc27cf80ad9f9b8dde3f3e21935d46be66d649635321d3407c", size = 577288, upload-time = "2026-10-06T20:31:06.776Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/b6b5870b51e004880d9a216313ea7d4f180961c5869f32e58e8cb9b71e96/asyncpg-0.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c032869fd9c3c9fd1a86ad67e53f63906159068087c2674dd1e19be3cffff571", size = 683362, upload-time = "2026-10-06T20:31:08.078Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/1f450742bc6eab0c015cae26aef94fac2ff29433e3f18a019126c3912c49/asyncpg-0.32.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:0c764dce865b41878396e736d4d2c6c6ce3a8e1b61d1f6bb292e30d265ae7ca6", size = 706652, upload-time = "2026-10-06T20:31:09.524Z" }, + { url = "https://files.pythonhosted.org/packages/05/dc/13f3c0ef7e867bafdccd470e5cfae1f2fd9a7085c771546bd4b94018e043/asyncpg-0.32.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:925ce1cc54419d468bfb77632d91e5e2be5be0fdf9d43680c68fe7cedf87051a", size = 3698244, upload-time = "2026-10-06T20:31:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/b00ef3fc0d861c28a1937f08d2c7f6e6119c152b414d50fa800c3aee83b5/asyncpg-0.32.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cec40b66a36b14921c155db78631cd96ed00e225fdf38dd5532e9aef350a498", size = 3801314, upload-time = "2026-10-06T20:31:12.964Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/215067d97a13206ce1565da920ddbefe5a1e5f89903e6de862fdd0a034a1/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fba43a9a230ce4d2b4593b761b8e03630c613c282b24566e27c7f53695273b1", size = 3598650, upload-time = "2026-10-06T20:31:14.797Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/2bfcb5c9b04df3f17fd367647c9f3ee9fe64ea0612b509a6b1832afcedae/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a8f7fa8304f757e23cccb8ffef6a6fce0b6320ffc565a884ee3cd0dfad1ac5", size = 3762739, upload-time = "2026-10-06T20:31:17.186Z" }, + { url = "https://files.pythonhosted.org/packages/08/45/e6b37756e6c8979fe070e9821654244f38319493f5b0589e549d9a40c001/asyncpg-0.32.0-cp313-cp313-win32.whl", hash = "sha256:d809399022e244eb86bb532a4ae9a45746e0f6dc5154fd6aa2f6ad63fa3f5373", size = 551065, upload-time = "2026-10-06T20:31:18.812Z" }, + { url = "https://files.pythonhosted.org/packages/ee/46/0a4e92f4310da644b28595b22ef2fff1ffd3dab84953dc8b4c5eef72b764/asyncpg-0.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:38640b106705fef8b0f46cdb5fd9dcf6a638eed5cadb0f441714a21405ca8a0a", size = 625571, upload-time = "2026-10-06T20:31:20.571Z" }, + { url = "https://files.pythonhosted.org/packages/35/f4/48ed4b580b99b1fabc480c707229bb8f1e4ba0f5b24a50822b339efe1e48/asyncpg-0.32.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78145adedfe51dc2fda623e6602cf816dabc2eafcff693bd50484321a1c9034", size = 576342, upload-time = "2026-10-06T20:31:22.29Z" }, + { url = "https://files.pythonhosted.org/packages/25/25/a30ca6417f9142c6a63a7caf5f33717902b2d0ca8a8ff8fc72c6cc2fa77d/asyncpg-0.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ac18d9ee7a8ca70aed276f79b249d9f37e4d55e3525db1002b5f0b62ddec4f5", size = 691699, upload-time = "2026-10-06T20:31:24.168Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b5/59f10f2381a073c199cd868fce0d8f7aa448b08412de4dc4dbe4118bcee9/asyncpg-0.32.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e1120ef2ae3a5e514c9ea9fce83519ba692710ea5f38434eadbbf12789073dfe", size = 715194, upload-time = "2026-10-06T20:31:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/59/79a5aebd58250bedefa6dcd43b22b037d9cf0054ceb4c718c53ebf04e63f/asyncpg-0.32.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa68acb42f22436597016e5d7feef7b0b5c49b4c56aece3fdb3ba0da2326cb2", size = 3729978, upload-time = "2026-10-06T20:31:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/68/db/fc91b503b3ec66cf242d83c799388285ea5f0ee238435d53dd9c1a8648a9/asyncpg-0.32.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63417b8f7369c54f6754c1fbd5a2968fbe632ff55bfbedd56a0177b6a96bd251", size = 3794539, upload-time = "2026-10-06T20:31:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/40/bd/7359320499fdb2733206191b8fd15b7ec602656cbc1444bff7a8c66a365c/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c6366841a792d0a4d16991de240a8053b7c4772a18a5f27fa6fad09c0e359fb", size = 3632884, upload-time = "2026-10-06T20:31:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/18/75/dd3c3dd99f1db55b9736d23a44da29501f07f852bf4df91507f37b156fb1/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3ef1dfd11919280e011ffd1c873323c5088a94fd2c3f77946a5250cf306e2eb", size = 3764931, upload-time = "2026-10-06T20:31:32.916Z" }, + { url = "https://files.pythonhosted.org/packages/38/4f/161b275759725a774d170a383c1208996865ebad50d6891e60d35461a3e6/asyncpg-0.32.0-cp314-cp314-win32.whl", hash = "sha256:77cf9d7023f063ae6f9e443077b55af0dc1807dd9afff1ae656b93ee0cddedc9", size = 557690, upload-time = "2026-10-06T20:31:34.856Z" }, + { url = "https://files.pythonhosted.org/packages/b5/03/880d0db1faedf8b740a57a7ba50e115651a0f05c5905140195813879b086/asyncpg-0.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f87452025b47ce80dcc3a0be2b5d1f8aab5deec2516d266f1643d4e53cc40d5", size = 634859, upload-time = "2026-10-06T20:31:36.512Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/2e86b462a2a2a795eaa7838266db019876b8e7a12c465b903517a4e87fd0/asyncpg-0.32.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0e4508a3d62b0f42d7a99c030c364050b11e75f61c9dd4861e5fdda7cb60636", size = 594013, upload-time = "2026-10-06T20:31:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/5369c4438496e654121cbda75be2e8043d1fcae3552b856d44011a19b723/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afec11e0b9c001e69966becacd2f948cc8949b4916ec4c0f4dc9b52e47de4528", size = 743832, upload-time = "2026-10-06T20:31:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/4b92582c2339a164275a6418ccaeeb0453b72f2e0d7003702379cb50e852/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:418d266a553e932bf961bb43bfd610ee6c5425fb1b9a599a5828fd12bae8f5c4", size = 769568, upload-time = "2026-10-06T20:31:40.691Z" }, + { url = "https://files.pythonhosted.org/packages/3d/88/919d9ff7ca3c3b96aa404b88b6a53e142b4422623c5ee5a69c4b733240ce/asyncpg-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1666e1b747ebbc75c87cb31972704ae8a3ca15b950f94456e97d26781c67d10", size = 3948962, upload-time = "2026-10-06T20:31:42.456Z" }, + { url = "https://files.pythonhosted.org/packages/27/8b/e9f412ae9a3e3f0eb23415249e8d5933e7aeb01068b4083fc86714043d1f/asyncpg-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83510bb25d38f0415e155aa3a7af78621369891f5ecd8730d012d9cb26143ffc", size = 3874815, upload-time = "2026-10-06T20:31:44.094Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/24364e9ff7bb9860548452513f295306b12f5b24e8fb0b78f1605c443946/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87957755d11639cf248c6aaa094eee9d150f07065866d1710c9427e02dfc0790", size = 3762465, upload-time = "2026-10-06T20:31:45.908Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/33cb7e805ec6806b196473e2c7a2ba9d5af3ad2928930aa06359c8eeef87/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:764227423bf30a3001d3da6df90e82d30a2a097d762e4ee5fa074236eda262f4", size = 3797285, upload-time = "2026-10-06T20:31:47.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/e7/85eb86d6040725f5c191fd6af9f10769c60ed971634b47f4b4bcab293d44/asyncpg-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:f2342b1f3e87b2096320a77edcbb830fbd23b1d4d4842c57567764430b95e4fc", size = 594006, upload-time = "2026-10-06T20:31:49.197Z" }, + { url = "https://files.pythonhosted.org/packages/f9/aa/ea75defe55718457bcf41cde42248db5bbee65fce8c6f0a0e43d9eca1723/asyncpg-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c3a48908cb0a02393e5bdab7fa92aefd700f2a93212bf91f04aa9657b4f554d", size = 674647, upload-time = "2026-10-06T20:31:50.547Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0b/078d362872c6c72dd5d11c214dde8dac65b1c87ece96fd2fc2f786a8f66c/asyncpg-0.32.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f8eadd207c26850a2e15f3c2a1096b5d051ea6758a26f2f3e65ce16f84297ed8", size = 624589, upload-time = "2026-10-06T20:31:52.291Z" }, + { url = "https://files.pythonhosted.org/packages/5c/83/e0145d19197b965438693179c88dd99cfc69bc1bf954815f44762ab88843/asyncpg-0.32.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58975b1a51a100c4716ebf22f84c249d27140f7b9385b64ad9b676836f1db9ab", size = 689708, upload-time = "2026-10-06T20:31:55.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/13/f394919a59f104288b1b17fb6c7a3ac4738b8c555690a63caf603f91ca83/asyncpg-0.32.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:6b95fc2ebdb4af072bfa8b64c6d0397b49242d17bef1c0337857904f9267dab2", size = 714408, upload-time = "2026-10-06T20:31:57.504Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/1123cf41bff78fdfd80e6fd143cc86bf1ef2875af8f5d8742c03f471e913/asyncpg-0.32.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a759f98c5652443db501b20041aeee548e9a04fe7ae939067321acd207218447", size = 3733440, upload-time = "2026-10-06T20:31:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/ff4b045e85d7bdf6f61f67c285800abd6e82f26319671d7f0dfadadc1aa0/asyncpg-0.32.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceea1064500d0d7a46c092cdbe9752064c23b720ab0e0bff83d1030fffe7a50a", size = 3824312, upload-time = "2026-10-06T20:32:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/1ec7eb6e20f7e8ae120a41aad9669044cce964f39773baf644897a046aee/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:543f02790d086244c7cdc849e4b671b6c2048be0242b78d943494da6e80c0001", size = 3637212, upload-time = "2026-10-06T20:32:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/79/68/528e362eb5adbc1a7defe4c5f157756a031346d3efa9920467b245e4ce41/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f24d20a68f0e37ca6fc490388e7eeb48abab3da0dbf06248135ed6179f5f521d", size = 3791355, upload-time = "2026-10-06T20:32:04.415Z" }, + { url = "https://files.pythonhosted.org/packages/38/e3/22f443f456bf93d1806f43a820da8ee463dfe9b93a9d77a3f00fedcdaad6/asyncpg-0.32.0-cp315-cp315-win32.whl", hash = "sha256:110f72d33c8b944ab421ca383db0b8849cfeb861547fee6cbb61f65a6bcd0985", size = 557457, upload-time = "2026-10-06T20:32:06.52Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/ccb76555a333f543c4d6ad6422b616efc0811dbbde5054fda071e249c7bf/asyncpg-0.32.0-cp315-cp315-win_amd64.whl", hash = "sha256:6d1d1cd1348ebb9b204b5f56f977c5d4380674c25cc094064bf32bd9c3b7273d", size = 635573, upload-time = "2026-10-06T20:32:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/38/70/dff17e837ba0eb4347bb33da33f54df87230d3d176793d4bb2ad7786b1b8/asyncpg-0.32.0-cp315-cp315-win_arm64.whl", hash = "sha256:cd5d16b3a5db37c1e6e445e362952b4af569f85f94e162f947bfa8ea25a45fa5", size = 594218, upload-time = "2026-10-06T20:32:09.717Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b8/c5506dbde0cfb213963210fd0c80e60036ddaaa883ac0d3c55d05a10ebe8/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ea1a72a00fe705b68a9727c3d538c4c56690af9bb1cbbf3c089f5d3ddcccea0", size = 741693, upload-time = "2026-10-06T20:32:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/9f998c651aa5d66b59ab6c13da71a15d74ccb1ddc4d65290ea5e2e5aedc1/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:ed3ae4c3659aea1fb0e3a6c1061fc4c64d9b7a2a8f4a27443dc43d74fa84cf03", size = 768101, upload-time = "2026-10-06T20:32:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ce/d8c63a71e908f5d80de1a3a057c8407aaea07cf19980d4b24ab624943c99/asyncpg-0.32.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db69b9cf879bddeea41210c80b8c8877bfe2709e2bee9d18d5a5c00e7eb75972", size = 3940715, upload-time = "2026-10-06T20:32:14.544Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a5/5d2b17682e297e39206eda1dfe0120fc239e84d3440b39ff7c9cc7ec83db/asyncpg-0.32.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bee7bb5394bf55fc3bf4144625c33f298949961acdb1e0d67e60f958ac9a2e6", size = 3907504, upload-time = "2026-10-06T20:32:16.212Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/38ec7277f31f26267a0a0547d0997d936850d05007d1e0e1041bf8070e1d/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d74eabd68e68861333e3fcb92b520a2a851f6485abf4b723887590399d4980c1", size = 3750324, upload-time = "2026-10-06T20:32:18.061Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/089e80eda7d543a49875687a84121e2ad61a7c69698963623ee77372c4e9/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6af2af292a93d5ef800007c8f8f66b85af2a49b49e4b56a10685a0dc24a6af83", size = 3826457, upload-time = "2026-10-06T20:32:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/38104e60cda6131977f95b634d45536ddc1cde53ef8bc765f9056e3e17ee/asyncpg-0.32.0-cp315-cp315t-win32.whl", hash = "sha256:d148cb6a9081ed999ca3cd0d95fb9eaf79bf17d885bba93c83de52273d2fe0af", size = 592437, upload-time = "2026-10-06T20:32:21.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/09/85cba249db0910708826ea428b32a4a05630df993621c369bdb8d42c73c5/asyncpg-0.32.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e101801b4124e905da0732cf2b0d838f682a9ea5273d7cced3d54bdbe744e6f7", size = 672417, upload-time = "2026-10-06T20:32:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/38/11/ec5f7f306dd361aa9558f002cbb6acfa1e9ba32fa59b8f53135fbdfa14f1/asyncpg-0.32.0-cp315-cp315t-win_arm64.whl", hash = "sha256:3bbf08c08e31f43be858255614518e78cdfb343571e557e818e9fe736334f4c8", size = 622767, upload-time = "2026-10-06T20:32:24.64Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[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 = "edg-acknowledg-forge-gitlab" +version = "2026.10.15.0" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "alembic" }, + { name = "httpx" }, + { name = "hypercorn" }, + { name = "quart", version = "0.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "quart", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "websockets" }, +] + +[package.optional-dependencies] +postgresql = [ + { name = "asyncpg" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite" }, + { name = "alembic" }, + { name = "asyncpg", marker = "extra == 'postgresql'" }, + { name = "httpx" }, + { name = "hypercorn" }, + { name = "quart" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, + { name = "websockets" }, +] +provides-extras = ["postgresql"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d7/41511ee2696f14be4200b524d9553dc4295e2bdeb20aa8962c3cb25e71c6/greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324", size = 294075, upload-time = "2026-09-14T14:25:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7b/b509624970909294cd064ff7346148ca9941c21bec9026d7873dd254e9fa/greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa", size = 613429, upload-time = "2026-09-14T15:12:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5c/d2eb503067f9ba20875ef8c87681f29a64f53bbbbe4059a5d7c53179d442/greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2", size = 625405, upload-time = "2026-09-14T15:20:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/9b071d11c8bb9f5f38cccacc38fcc234d91997a4c395cc2bf43ecae89642/greenlet-3.5.6-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4fb8e59f68845d56c23c031dcd79c329f345e4a9d2ffac91c3d1ab366bdc457b", size = 633270, upload-time = "2026-09-14T15:25:04.864Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/63d4477ce31dff2fd802a9a20240f6606aac85977e0fb18443aae33de3f6/greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba", size = 624428, upload-time = "2026-09-14T14:35:56.895Z" }, + { url = "https://files.pythonhosted.org/packages/88/17/ac11883ecc9da19c681c8b763ee39e6f7dca2aa81874eb11a075d3cbeb00/greenlet-3.5.6-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d701eab36200c36224833d07dbdb709adb7fd4253429548ddb5e547b8ed40586", size = 428068, upload-time = "2026-09-14T15:28:35.872Z" }, + { url = "https://files.pythonhosted.org/packages/ad/aa/9cde4e00688eaa2a03b91d12e4681439a87e6aad860399e0847af6a014ca/greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae", size = 1588385, upload-time = "2026-09-14T15:10:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/24632b5ec186b64e21e07a8f53ce5e15a7e9cb33eddee99a5fe16379afa5/greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13", size = 1653119, upload-time = "2026-09-14T14:35:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/019d2ef898f4b9ac845167f1c6f73229e9a4e2439362a5e2ce50205a19b0/greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016", size = 323317, upload-time = "2026-09-14T14:22:38.836Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/439df999455e3bdf02b1c68f3848d4020385ef0a01f89f706b07bf148a65/greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32", size = 307739, upload-time = "2026-09-14T14:23:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + +[[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 = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[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 = "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 = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "mako" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "quart" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.13'", +] +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/8a/13962df31309fa024b1811102981577b1702916779d3f17067bbf1f7691d/quart-0.22.0.tar.gz", hash = "sha256:6ba567bb29e0ea66f7c0a0297c2b6225bb531e37dbf9b75dbf4a6e1713c4c934", size = 65475, upload-time = "2026-08-19T19:53:30.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/80/0159d6fe2fc76915f2354e5b9187082987f7d648f0298d49770320c086ef/quart-0.22.0-py3-none-any.whl", hash = "sha256:bb659545f1a8a287a14df9434b9225a3d4738362a3ed170744d0e03bb9447b50", size = 78912, upload-time = "2026-08-19T19:53:28.961Z" }, +] + +[[package]] +name = "quart" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", +] +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/81/34396f67e09e7a0609261f1ef0f43b26f5d67e8f2dc4d34b4953061560f2/quart-0.23.1.tar.gz", hash = "sha256:1ca848415910bd2eb75e9d9b452388f892a37be222602a373622e6c633d1efbf", size = 65636, upload-time = "2026-08-29T15:58:35.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/c1/26dca56249da1a889ebb946000ab272712476209234f714ad3e8013ee005/quart-0.23.1-py3-none-any.whl", hash = "sha256:78cf3a7249ab09f9e03d78b0b5e2472c4c09ce4615a99c2b1aa9a35261243b66", size = 79388, upload-time = "2026-08-29T15:58:34.147Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/44/311bac6b6ef81e4dfd0287d04900108b1f5c00c9761dd3c0a2b7b9d0f86b/sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd", size = 10544216, upload-time = "2026-10-07T17:33:59.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/cd/264493ea522b887ac71949d442ef6a49ca04504e1090b427e878a71d5bb2/sqlalchemy-2.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6d147c31e189541ae7cd990482c4f960f9e8abce186551225fa355856dbf1a5", size = 2463376, upload-time = "2026-10-07T18:17:21.503Z" }, + { url = "https://files.pythonhosted.org/packages/59/16/1dbc3674709e945d113cfe0f652431cfeda0aa5999c0737444e7e4a416f8/sqlalchemy-2.1.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55072780d1aae84dea443ce27edeb745f6cc4d19ad89416abbb6b49712080e7c", size = 4628615, upload-time = "2026-10-07T18:37:37.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/24/0640dfb48fde362b83eaa122691457cb9a13f51d50cd6064ddcfba667c71/sqlalchemy-2.1.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:343a0493a81278bfe30be1ec81214a55f2f44aaa4662d230be359ab2aa18cc2a", size = 4650872, upload-time = "2026-10-07T18:24:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e4/5aec21a9e6ffadc919854fef1cd92b6f699ee204811e78ae1b1f9733da7e/sqlalchemy-2.1.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8080022e101afb17565dc5a358a165ff4a20cd97b20b4db49ebed66315b3c733", size = 4380816, upload-time = "2026-10-07T18:59:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/7aaf2b01d4d9c7168a55e0c318ab494ab436b434ebdfd4977fee5fabddf9/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:948dff080b5ac00c8e63bf9e59fa70e386cca1476f55c672a72b6ec12e5cdb05", size = 4566541, upload-time = "2026-10-07T18:37:40.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/61/3e4df04dd09d1db05ea31a2d7dc015aed26e33fefaca610eecbfe9b8d26b/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:12642e105b4e0cb2ca8428037368c1cbcded7b9d0344174607174d82b700e1eb", size = 4377842, upload-time = "2026-10-07T18:59:42.612Z" }, + { url = "https://files.pythonhosted.org/packages/53/4f/c983249adefed608b0cdc13bffe43a47a032316548e81bfdb4b6282a5b56/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976bd3fecfcfa58d69eab67e76325f564ed775aa0c0accf138ae17324b461431", size = 4621091, upload-time = "2026-10-07T18:24:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/257469b63c8cfad892b796c54a1392b3dfef93ee9273af5a8f49065d77ce/sqlalchemy-2.1.4-cp311-cp311-win32.whl", hash = "sha256:e2ace725a430e5b303fc3c422196966328ce77fb4fd053ad85572b46ed5fb71a", size = 2379577, upload-time = "2026-10-07T18:24:56.929Z" }, + { url = "https://files.pythonhosted.org/packages/3d/53/eae7fc135ac36ebc6385e87975ed5672d6311f0350f0358f38906a877f2c/sqlalchemy-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:3c998d70e60fc95e93e5971395818c50f8a34396a6352075256fefac6b5cf81b", size = 2430336, upload-time = "2026-10-07T18:24:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/81/fb/73b7ad29f65d9a114a3b42fe10ddf654bc360855dd29455381d4c7c34f97/sqlalchemy-2.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:d045e63095828d2f1fd84d499936e6791522c15c390373fc755f118e4040393a", size = 2394934, upload-time = "2026-10-07T18:22:34.762Z" }, + { url = "https://files.pythonhosted.org/packages/49/5e/cb5b078e007340661b010fa8bd31ce27468f88e09b35266544df4e0c52ca/sqlalchemy-2.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f953be9ba26039a24a5205c65d33518b608ce6f4f0f4e9b9c14eaf42a10dfc52", size = 2466469, upload-time = "2026-10-07T18:17:24.049Z" }, + { url = "https://files.pythonhosted.org/packages/b1/98/44e2fdc5bc053dae559bf4f4eb7967ceecbad162299ecfc8de2edc3fcbe7/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac64fce94c5b389062d2e3806db5dc780447591e0dfd5ead218c884f0703f2e", size = 4668213, upload-time = "2026-10-07T18:37:42.294Z" }, + { url = "https://files.pythonhosted.org/packages/08/25/ed2262f964687b06f10c2c98b2dc9c9ed211f7cc11702879969a9ac217e4/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e5045fb6aadbb0f978ab9b9d8822f7b7a97d2281814e7d13d791155664eace3", size = 4720857, upload-time = "2026-10-07T18:24:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d4/fab64c61d5d22ddbb077afd1e6b29b498bdacdf6406a03f53566e7e01686/sqlalchemy-2.1.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3a026436c51f296aa1d01243909a3b76490950e927824b10899a083cc26e7c3", size = 4369351, upload-time = "2026-10-07T18:59:45.483Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e4/33413f0fafbcf3b332320aac2c1e40f3b4f17e56359a9474cb10de4bee8b/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71040390ef01c85e9d26e5c83cb0c5942dcc8725c49186430af160ce2f54234d", size = 4591496, upload-time = "2026-10-07T18:37:44.433Z" }, + { url = "https://files.pythonhosted.org/packages/bb/65/19821440cbd5c93da053d627b3e402eff11ff252bfae37700645b3c155a4/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:07c60abaffb980b7382f2c75be8a5279c2b5df2626a0f5d751dd942799bf3b5c", size = 4362379, upload-time = "2026-10-07T18:59:48.278Z" }, + { url = "https://files.pythonhosted.org/packages/01/e3/168a0f93efd6ec40f59645a7e45ab08918e0bc8ecf07656e4ca09acdcc30/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a577e2127e52b0fe2bc54c73abb375a20ffe6f59fbc5568ccafc233f5bfcf8ef", size = 4667997, upload-time = "2026-10-07T18:24:48.72Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/0a852ef65864acd8d577d7aa6f67146167382bd6faee7a7586b9e6e28275/sqlalchemy-2.1.4-cp312-cp312-win32.whl", hash = "sha256:6c79e0c824d51c586757ecd342160bbdede9010df04bb71b9bbfffd5c7b6ee29", size = 2376664, upload-time = "2026-10-07T18:25:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/a5934263bb1d712f743289ca224ab3b87e3570ac157802291e37ab85d365/sqlalchemy-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:dffa69d2f3ba1933c1c1882dbef8fb3231b33eb19263e8b8c5cea24995071f06", size = 2429357, upload-time = "2026-10-07T18:25:02.565Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fa/a2323d81384ff214aa189057b7455b63623e66f28208b982e86c3cb042f5/sqlalchemy-2.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:e30524ae24e31d83e1b5f734862882c442f4158e3566f2c5f5e9bd3c659bb517", size = 2388756, upload-time = "2026-10-07T18:22:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e4/23174288ed2c03d6dbd5dfacd69e28303ee95f49642a8ed0544932999fb6/sqlalchemy-2.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:70006e9e6157200b795beeee04bd5cb15bccb40a14de595eb9f5dcf5945ed244", size = 2460507, upload-time = "2026-10-07T18:04:40.044Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/254fadc98bfd600445b976e81c6d777b08a728a415c3b77a8c8d35b89a83/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3341ddc430733cd961bc064889f42712a0b4056733a21c83176842aad67d12a6", size = 4594505, upload-time = "2026-10-07T18:16:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/83/6f/ac7beddc57c9c87bd77bc1c158fcbcdc20822f1873bf33ea3480d04e865f/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98f7a4bfeaed3722804f737ae2bd4077b35e57d6f4531fe612bac8160cda5acd", size = 4647364, upload-time = "2026-10-07T18:34:51.721Z" }, + { url = "https://files.pythonhosted.org/packages/0a/82/fc3891f261c4738a8b90cfdd805fe292d1af3b77f680a63b7349304c74e5/sqlalchemy-2.1.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec5d079935f67febe0ab8a3a203ad591b99508adc34ae0027f696dcb20373537", size = 4311619, upload-time = "2026-10-07T18:38:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1a/160c1320ab20e764a29721dc3fe7c31af34e291c652dca875d1ca6022b9a/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3d675b0856b6703b29d023517a4c19fecfbb55214ff5c72cd813527e40aed9b4", size = 4518031, upload-time = "2026-10-07T18:17:05.615Z" }, + { url = "https://files.pythonhosted.org/packages/30/2c/15a204333896e5dc63cb089ea20ca3ebc3c892bedf9fa00cc1a65e20d7b5/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0bb9ee6a38cb36240dc88da11888348f61506047be54de3f09496c3b0ead6f5", size = 4312818, upload-time = "2026-10-07T18:38:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/a6/55/5e78d288f198598f278b4b7baef42f18e039b14b1e1045e9df3cf571300d/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61a2c48771cf314b6613d327c795902bbc0eb6d6169deb23b35004ba6ad6cc0d", size = 4608584, upload-time = "2026-10-07T18:34:53.69Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f6/e83b93ecc6e6528623fd7aa2af27ff0660d22354b78fe6ccad03f9ecbd9f/sqlalchemy-2.1.4-cp313-cp313-win32.whl", hash = "sha256:3fd608a06bafa768ad5711df4e17eb058bdc490e9df7d39b12a90947471e8712", size = 2373880, upload-time = "2026-10-07T18:22:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/8f/46/afb02975023db6aa4b8608177c2fae17d0b435d9cbfcb5df4fa6e65a8078/sqlalchemy-2.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:b756d74527c56a7e4cfae297f7930c1d75bdf4b23f214c8c13779746d28060cb", size = 2424430, upload-time = "2026-10-07T18:22:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/21/e5/76dc82d59186b98b27589b33b01175c0d49512679276170271d9384418e2/sqlalchemy-2.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:a64d54015233f824f171009977bfbb6b08bd0347b700cf17cb047ffb94c4148f", size = 2385057, upload-time = "2026-10-07T18:11:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/43/b0/6675a01f4e6215e0a809d28a800953294ab31370fe8c4bb3eb9e28c0b5a6/sqlalchemy-2.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a2f6164c0527cd8fc4cea79a5c9d8369ffee417b8ba444a42342f36b91deb75", size = 2463645, upload-time = "2026-10-07T18:04:41.615Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/4630a4009ea08a0769d5ff6517c7fc978f6a63eba32e08c44b98c284d7e4/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6929a11ad26a91a4efd891c1252b373c2e88f056910b83ec6030ed3f2cbcb734", size = 4587911, upload-time = "2026-10-07T18:17:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/0e/02/953686f44448b92cc628245687a242799b6eb11ef30ad2bc7adacd51986d/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14528d37d7d46a92f2a483f188f7fecd86cdd789254a0412b960c9fc5e9efd6d", size = 4617539, upload-time = "2026-10-07T18:34:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/13/23/a44288ab4fa12e51c9d390e7d798d70a45669ddcbddc9dd9b5948eb1aa3f/sqlalchemy-2.1.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2cb669c6bd1f19caf51db6e3c4fdd4cbb76f9db3ef81c3aeb5e288d9bae101b", size = 4310883, upload-time = "2026-10-07T18:38:50.265Z" }, + { url = "https://files.pythonhosted.org/packages/a3/39/1c441ac015767f619a9e6cc306905bb042f94b84f2a1e930e989e9c6e209/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:63dc25b21fd9a41dc09b7aada4b3b0d97cf4b6414f74bced6ac45326bc799ac9", size = 4516511, upload-time = "2026-10-07T18:17:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b9/f54ea5ccb27d9a712d90d1617050bee761df25dc1fb5e0b7d2aa867deb51/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:308f96d24e773d64609a2a0d1161a068f9f6e9165523bc4e07aa9c45f0c4213f", size = 4312314, upload-time = "2026-10-07T18:38:53.249Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/c1e39287ee988e4c2e25c619959b8fb15b297734be040653fe85b57517ee/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93b9416b9011a3b7689a933e04ac9f61d15686b6cb1948ebc1f41467153116c3", size = 4577833, upload-time = "2026-10-07T18:34:57.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/78/5f1ae1911d2b20ccdb39ee522118533a4b5262b6e5e06bbcbb1ebd1f4617/sqlalchemy-2.1.4-cp314-cp314-win32.whl", hash = "sha256:89db94855287fdac98d74595cf13ea59fbffa608d6400ff972b0fd4c036d873f", size = 2380724, upload-time = "2026-10-07T18:22:25.374Z" }, + { url = "https://files.pythonhosted.org/packages/ca/93/4dfa4ce15d082011fb94e06e7c6b4c2957a3f0ddeb8fe9b89d007bc058d7/sqlalchemy-2.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:080f8d853aac5bb5620f0ae6f46527397cf18dce0ec2b478b478469ef3cae2c4", size = 2431689, upload-time = "2026-10-07T18:22:27.144Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c4/6f6c29eaf459c4c2d9b7d24e300bab32043f8f8a936df863f3b886b5564a/sqlalchemy-2.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:64d41be1dd88f184de1931f0173f4827122a1b49fd1150656641200c0bdf640c", size = 2396106, upload-time = "2026-10-07T18:11:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/48f851411665e394f60c669d1f9494d660f5f1fe46e275f9615cfc812a98/sqlalchemy-2.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84272f329c15081a1e09b4a7261118b4e8a547f43e00fca98e55bbdf19eff3be", size = 2503038, upload-time = "2026-10-07T18:19:41.094Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/bf83068bda4051d7fd719c14cefc15d8466ef1e3656b9f4401b0509b11e0/sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3f58bd26fc010ea28976d401845e4e6ce02e1b7c0288b3ea9c9a3c396f0bcc", size = 4919623, upload-time = "2026-10-07T18:16:45.399Z" }, + { url = "https://files.pythonhosted.org/packages/56/de/57eb70d56b70d22a9360d658b195834ecfdeff7a7bc5c2e3a7fa7a8f7823/sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82d728075d42bd457d09655cf22e99d772a648c6f67e86743a4f05b7d063ca18", size = 4830238, upload-time = "2026-10-07T18:37:04.468Z" }, + { url = "https://files.pythonhosted.org/packages/70/3d/c410e9e79a53fff4c04444da609fed6404868d250f11fe8bc53d827bfb0e/sqlalchemy-2.1.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0970394ec5d9e397aafc5bc5fa2b7f8b58cb191f2703006b19a96ef4bf00b8d9", size = 4508380, upload-time = "2026-10-07T18:38:44.277Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c3/01b93821ba35b5b162e79c613279d960a120767694f656da1c1374dd3ed3/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6005f2f5fcd67fdd721446128e6a2a1d18f77387a604fbd26b0006a086b33096", size = 4775311, upload-time = "2026-10-07T18:16:47.724Z" }, + { url = "https://files.pythonhosted.org/packages/c7/88/0b40754e4d851d33548792062c23467a3d8dc07f2eff90cb19e4c404fb4c/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0e01a3e199ae219381c4889993c5584b1b905fffe6830f639adb6770036a8913", size = 4508558, upload-time = "2026-10-07T18:38:47.857Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2f/3916954eca5596d9e93fccd2ec0e45fd8c65981debac0ec4617639ded6ba/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22129e7d00ac66b291840c4dc83a9c497456ab5bffa682dcbfdc2356f9e49e5a", size = 4767114, upload-time = "2026-10-07T18:37:06.792Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d6/6a29716aec6ae17cd77e27b5e0dedc68cf9068594f2b601806c1d146427a/sqlalchemy-2.1.4-cp314-cp314t-win32.whl", hash = "sha256:bc33d3e59d4e84b8866cc9ba13732585e37212dbe3542cb09f232682b36f47a5", size = 2439584, upload-time = "2026-10-07T18:22:44.434Z" }, + { url = "https://files.pythonhosted.org/packages/34/79/2f0b33647d2d26f098269096c1864c0b4e81095354cdedb95192647f47cd/sqlalchemy-2.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:346d144e8912ae087b10d3c2081657cb634728600693eee6dbb71d7eb4768101", size = 2507217, upload-time = "2026-10-07T18:22:46.176Z" }, + { url = "https://files.pythonhosted.org/packages/93/e5/869c1ac0a21e17e4617b6a7828b50320bedb7074b6d67aec59299be5cdba/sqlalchemy-2.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3e5de57c71b3460e2ca6137e82cd3cb8c9f711f301f50d5c77156fdb9c822999", size = 2425329, upload-time = "2026-10-07T18:12:20.595Z" }, + { url = "https://files.pythonhosted.org/packages/2b/8e/a082a165b473dae45d2f2f79be15f5c405ac579830c64253efbf04695177/sqlalchemy-2.1.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:418786f05387ddb66ee683a1d016c5a8d9bf7be921e6ee8f285c7b6ac961a731", size = 2463572, upload-time = "2026-10-07T18:11:12.053Z" }, + { url = "https://files.pythonhosted.org/packages/d1/35/74db254005ecb384533973b157ba1fc3fe5bc41a5bc6e0500ab8369c49e6/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:283914efed30e4d44301e36ac90ad048570538b8a70f072fe01578d9b205d09c", size = 4591740, upload-time = "2026-10-07T18:01:00.314Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/5cadd72b0c26b6ee7c1e6950cb9f0cfc383246a842314a1b2a87f455db25/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2eacdbeb990b80235763860923c60a8393745b66f7149a734980c65896da72", size = 4627040, upload-time = "2026-10-07T18:09:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/8e/78/aed93cc373f61b57625e1f9f84bbf12358e32e935e64fa098f3a446e1203/sqlalchemy-2.1.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e43fca5fdd5f34a3f8c54107a3648d3139de8bbf596a189f3f0de94bd84949bb", size = 4333103, upload-time = "2026-10-07T18:33:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/ecc6bbd365671cdc512a59d42afa7c34b2833a8d841754918ae3f62d36dd/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2e1b5343d315b10a4a71da481729f66f830a561595e02b61e8a5a65d658325ac", size = 4519962, upload-time = "2026-10-07T18:01:02.268Z" }, + { url = "https://files.pythonhosted.org/packages/58/58/9f8f6157c2252aefe73f4a0b3859413bb720d14321aa7f367c691949aaf8/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:42c37c06adcecf444e8c981f7e9237a41bdd445c83da0df9e08b4ad958becbbc", size = 4331935, upload-time = "2026-10-07T18:33:50.334Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/a4ae4b95d17607004f01e9a085fb221087c557bbad77a3d87d5d0a5fd8bc/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:bab7f51d38766d6a64da2b41976f1b3f9cc2ff37d3f2f63bdbac876199f3a48e", size = 4589275, upload-time = "2026-10-07T18:09:26.872Z" }, + { url = "https://files.pythonhosted.org/packages/65/27/56f69293a01279ac0e6077b8c358eb0f1c2afc6aa17428414a86c8871042/sqlalchemy-2.1.4-cp315-cp315-win32.whl", hash = "sha256:1541ba5bf0f232cd61f9ef3df78c93977c72ba6031506a0e6d057b2a3ddb76e9", size = 2380494, upload-time = "2026-10-07T18:04:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7c/ff7e29f95996ed49b950afd531b89e7c8d15addb41735643d07090550090/sqlalchemy-2.1.4-cp315-cp315-win_amd64.whl", hash = "sha256:596a95611c217cb19c21f02f43c637cb507cab71dcf0467c5c7d98fcdd703007", size = 2431760, upload-time = "2026-10-07T18:04:27.275Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/4eaa4978760cd632093ea272e7c4f88223619202f5481f897e67d4377409/sqlalchemy-2.1.4-cp315-cp315-win_arm64.whl", hash = "sha256:0d1ca95e42ce3c18818f170b741d30a33b292c6f6b9a202ffd717e28fc99b8c7", size = 2395807, upload-time = "2026-10-07T18:30:54.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/b806fbfc61ade37c4f3aecec0874c345fb297b56a3743116dcefa3e4700d/sqlalchemy-2.1.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0f672ed6972164fec94a8f0b21dcf8545080d0727866335fb8adf9f4764ce6ec", size = 2499195, upload-time = "2026-10-07T18:19:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/4f9fba8340222f09287e936d7b76e6911a4e507c7d6373ada770e8f697d5/sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72e3fa41d1fdab87d4e88bbdd69c9522e2795549fbe7b07bcf4ae9ec175f4b11", size = 4896914, upload-time = "2026-10-07T18:16:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/55/34/c4aeec7bee453badd8b0e02c2021a13bd70ef01038303d05326e99f595b6/sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb2cb98d056e63e353ed697750004e07c79b054d73059ba3184ca3bb07296bea", size = 4846593, upload-time = "2026-10-07T18:37:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/6dd8504364e5f5efd328e98fea963e5a2e978ff8dcba70d95231314f82a9/sqlalchemy-2.1.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d66fdcc5506e0f8bb8d3f4f95125220a7cd6c46e8b1762750f01e9639973dd8", size = 4494821, upload-time = "2026-10-07T18:38:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/df/42/dc584c098bce29578fd0611cd6f36830e06b4dd2505d3020a0b592f4cf08/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:81f802c96dbf96e59c6982fa1b87da7868920fb0c27b9b81e560a62f57c2ccfb", size = 4752545, upload-time = "2026-10-07T18:16:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/8c/41/69a70c1419bea97e80f65ce09f4f626df464752b276f4f3d69ff6fbf2325/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:acf8982c70471a68aa90d1aba08b48860c55b3357ec84ccb0f09368ead2ce099", size = 4494093, upload-time = "2026-10-07T18:38:54.37Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bd/d296c2223e8417b350db215d94dcd344bc0dfe9deb7d810a21f7d8cd0b14/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:778094c83e36c430756a7e1a1ac66fc3cffb2c6a1067958fe6b920abcec7bc5a", size = 4771973, upload-time = "2026-10-07T18:37:10.93Z" }, + { url = "https://files.pythonhosted.org/packages/13/4c/c3a10d9da10e4e60808ffd1825547b383c0d7ca9e56d15cdae47c04e752e/sqlalchemy-2.1.4-cp315-cp315t-win32.whl", hash = "sha256:963348422b22f760e9462e56bc32bf4d95d224cc5b8c79a3c6e3b786d3d2a2b2", size = 2436383, upload-time = "2026-10-07T18:22:48.162Z" }, + { url = "https://files.pythonhosted.org/packages/51/de/8045d4ad1fd3a66c3b9bb576f3734c86015e19ae2f1617af92eb63cf9e58/sqlalchemy-2.1.4-cp315-cp315t-win_amd64.whl", hash = "sha256:fba3500e170d25f581e053009edeb0b158116084d91d465de218718d336b67c3", size = 2502237, upload-time = "2026-10-07T18:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4b/245e2315d331cc15765a2373e068445fbd28eb63beb23ea862828808c0bf/sqlalchemy-2.1.4-cp315-cp315t-win_arm64.whl", hash = "sha256:0a9a464bc360856b7ea9bf8aa26aab92ca115dd08149cb0e004063d5db13584b", size = 2420917, upload-time = "2026-10-07T18:12:21.876Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/dbf11a262f6fbb41390cab2d8e47a30ec0961018b68201607b599dd489f5/sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7", size = 2054935, upload-time = "2026-10-07T18:01:16.403Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "websockets" +version = "17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f7/8a90cc2abbe4709dff4450824beb07cbf7256566ee043c2ba3faa1d5fb2a/websockets-17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:569ed5db651e420b13279f9333443bb5b84a436cc66b599cbc535697ae4434a0", size = 217725, upload-time = "2026-10-03T14:52:50.797Z" }, + { url = "https://files.pythonhosted.org/packages/7f/85/e418ba2e7e412a5b35c42caf6d4fcc8ecee1a66edc4f2a5f780da775aa77/websockets-17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3892d76754b5f36fb40619f3ef09c68e5c3091f1ab8840964518ae5a41f30952", size = 215415, upload-time = "2026-10-03T14:52:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/b3/28/e4d7eb2e2e4ffed0b0dfbd2d1aa3c8101f42d34ac9f58b47b822c565d1d4/websockets-17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5436ffea003adb50e283ca0684a3fcaa1396104f841736c3322ee6582bd09e98", size = 215690, upload-time = "2026-10-03T14:52:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/4b/dd/e8718fa6114c4cd15b05133b548af985638e80774253c1faee8d49874c38/websockets-17.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9df9d048def11365d170b375b6ffc8b23a7f188c3560acd4418ba088ca2e2705", size = 224756, upload-time = "2026-10-03T14:52:56.132Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d5161c46f3eee2ae67cdec489532b51695a1c27ccfadd858dcd419ea26ac/websockets-17.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:376a693697ddb695ea282ead76060f4847f90e564b12b4389f2c7589e6fadb9e", size = 225026, upload-time = "2026-10-03T14:52:57.671Z" }, + { url = "https://files.pythonhosted.org/packages/d5/9a/3f83bace9636af07d7bb00cbae0bcb5bd1697892babac79664f3a2b3a011/websockets-17.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd63d0c7ed0d3d719c91b5a3861f0f0b3cec9bf223033ddf69d17aaac74bb6d", size = 226260, upload-time = "2026-10-03T14:52:59.114Z" }, + { url = "https://files.pythonhosted.org/packages/03/50/5347cb13f97430526b9c31e9b30fa639bb1d0f9d53074da8622b327cfb6f/websockets-17.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:48997ed4431d8006988788ef4b62e1fd3f053c7463b4fa793aa6c4f9e96a3bb7", size = 229573, upload-time = "2026-10-03T14:53:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/14/2b/7511082e3fe0cc3233ecb0c3b019ef12c1cd9df60ac1a7858f6093f490b5/websockets-17.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e312e07557a5ad348f4e83d3419773527f6e790c7f97928b1911d767b6ea1c7", size = 226819, upload-time = "2026-10-03T14:53:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/4f/86c1a9db323d4fdbf56cc089942f18328a48c3efbbad0d625a66a2195842/websockets-17.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:902ce8cafca2dc14cef9558a6fc3b45dbf7f121d1404bf2ad18a1c894555e48c", size = 225595, upload-time = "2026-10-03T14:53:03.768Z" }, + { url = "https://files.pythonhosted.org/packages/81/92/4f54f6031d97e284e01a0728cef38b095478dcaab81837aac8cb0e26ea6a/websockets-17.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e53d950e16d4bb672a5ff41fe3131e65a4e5d688d694e1c7074c8c9990bb3ceb", size = 222875, upload-time = "2026-10-03T14:53:05.7Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/c6d59b8b45c730a56ee5acf6c0ce9896356cba25ef3f9a4c9d1796f2e44f/websockets-17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:946ac2164d646e733004946ae39536b5af473853183d81da5962e29d36e3ad35", size = 225745, upload-time = "2026-10-03T14:53:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7c/5d9b91b43aa339b96551630940a847270c10a9d70243be4c81fe5dc6fb34/websockets-17.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:660aa158127035e741d4b1835dbe79ae18a1fbb21ecd236655f31d60110e68d5", size = 224342, upload-time = "2026-10-03T14:53:08.893Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/c90c24b0dfb12b8b6f0d5e13fc7cf9f121a2e072f7f54bb888da826b2012/websockets-17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4733fc2d99fe888261417b7e29995403a72d9ffa78629902882325ea141177f2", size = 225109, upload-time = "2026-10-03T14:53:10.495Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5b/f38ca1299c10ea1cfc7f1d129c65a15e4f4b281d1f3dc25891d5fb9bf9db/websockets-17.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c2ec7e51157a3fa0e9cfdb1a8969bab38d1c22ad1ace7c6cea006383b43a1ad4", size = 226151, upload-time = "2026-10-03T14:53:11.976Z" }, + { url = "https://files.pythonhosted.org/packages/f9/21/ff6089c6921c7ae0e1801a4948aa1a3831deb1596e8f0d1cd3a0c0e44109/websockets-17.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ada04d0262ab06527054a2a497f384d102698ff39b3865dc566a7d24b6f4058c", size = 223732, upload-time = "2026-10-03T14:53:13.864Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c4/01ca4212f665e351123c84e7f7156badf5da958ef8aad8781b538682c699/websockets-17.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c393a202df08e96ed619310f0cd78be700e532a57d9a6ceee5f80b4e35bef14", size = 224764, upload-time = "2026-10-03T14:53:15.411Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/bc17b39d1e62b771d8a417b714439252d7abfca21185242cc293d75b20d5/websockets-17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:af4c565b923bb5975401b8e4cedc2e17b2fdbf33b905737ee12384e6a6fd9507", size = 225002, upload-time = "2026-10-03T14:53:16.93Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f6/ccab831ab6a841a35134937a1794c0f3f09ccc604625505be061dec5b3e4/websockets-17.2-cp311-cp311-win32.whl", hash = "sha256:c81d6cdbacccda7e0eef3b076a457fd14c3835cdbc5993d2881580c2fb1f5f26", size = 218226, upload-time = "2026-10-03T14:53:18.376Z" }, + { url = "https://files.pythonhosted.org/packages/0a/18/4fcc23f2159393ad7a668574ee97ee5a135003bfcbdd56b30581110c0fe8/websockets-17.2-cp311-cp311-win_amd64.whl", hash = "sha256:55c5b9eab079540bfb639b40b07b7b467e5c5a7ecf97a65cc8665781381c9856", size = 218523, upload-time = "2026-10-03T14:53:19.947Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/5a3f4f75dadb7fbf980ea4b59d02528f87fb2d3c0ac120c2ff50d1dc1b34/websockets-17.2-cp311-cp311-win_arm64.whl", hash = "sha256:55f9a808a0e072473337c240c939849818276e288e2374b832255b5b791b0851", size = 218454, upload-time = "2026-10-03T14:53:21.417Z" }, + { url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" }, + { url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" }, + { url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" }, + { url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" }, + { url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" }, + { url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" }, + { url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" }, + { url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" }, + { url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" }, + { url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" }, + { url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" }, + { url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" }, + { url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" }, + { url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" }, + { url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" }, + { url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" }, + { url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" }, + { url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" }, + { url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" }, + { url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" }, + { url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" }, + { url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" }, + { url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" }, + { url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" }, + { url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e2/09ad9cec0fc7e39f983b52f9e49c44f89b7cf7a61d4761fa7fc398f003f9/websockets-17.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:2de1ccf298f5c9e0f27113836d742edb95f015eee3148f004ac386f7ba9a05b1", size = 215348, upload-time = "2026-10-03T14:56:41.037Z" }, + { url = "https://files.pythonhosted.org/packages/80/fe/c307b5d8cdf1852d00606a0403502f0ca5cd8a4736550bab70abce09f7e9/websockets-17.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:761cde41439f0be761aa460e1451a31e2e14baf4a46db6fe4913e5a06a90df66", size = 215621, upload-time = "2026-10-03T14:56:43.097Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/af8412f154cd0568afc043ab478cc8c1ebdf9337b25c85cb9a049d18cfcb/websockets-17.2-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:15a7101b660a9f15fac34108c92cefc9848f6753a50acef8869e3cd94148fdb7", size = 216569, upload-time = "2026-10-03T14:56:44.979Z" }, + { url = "https://files.pythonhosted.org/packages/fc/76/92ae57b985378036bb8133ea39d1e5cc4d97accad9cae38169426bdcef75/websockets-17.2-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:214da56dba368f61b3d745c77630b2d03c61c02da7b42fe80ef6efba079d3077", size = 216462, upload-time = "2026-10-03T14:56:46.771Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/e3b276473f7f38984990eb29cf525ffaed131f6136bedb929b5c2ce7151e/websockets-17.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80cbc645af23ac5c12096545c161626960114a1bc10f864760558d3b3e82ba18", size = 217355, upload-time = "2026-10-03T14:56:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a1/459ab96c5cda8a2164f594be6dc9f868de7971e6abafa696ea07534139a6/websockets-17.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:063508ce9e0db745f30ab52fc652f4e59efc79c2b74934b3837d5cdb974da620", size = 218612, upload-time = "2026-10-03T14:56:50.287Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] diff --git a/dev_tools/services/acknowledg-forge/tests/.gitignore b/dev_tools/services/acknowledg-forge/tests/.gitignore new file mode 100644 index 0000000000..e6af85531f --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/.gitignore @@ -0,0 +1,2 @@ +.pytest_cache/ +__pycache__/ diff --git a/dev_tools/services/acknowledg-forge/tests/conftest.py b/dev_tools/services/acknowledg-forge/tests/conftest.py new file mode 100644 index 0000000000..52019b51df --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/conftest.py @@ -0,0 +1,359 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Shared fixtures: an origin repository, service wiring, and fakes.''' + +import asyncio +import io +import json +import os +import shutil +import subprocess +import sys +import tarfile +import zipfile + +from pathlib import Path +from typing import AsyncIterator, Iterator, Optional + +import pytest +import pytest_asyncio + +sys.path.insert(0, str(Path(__file__).parent)) + +from edgacknowledg.forge import ( + ArtifactInfo, + CIConclusion, + CIRunInfo, + CIStatus, + PullRequestInfo, + PullRequestState, +) +from edgacknowledg.forge.announcer import Announcer +from edgacknowledg.forge.config import ForgeServiceConfig +from edgacknowledg.forge.db import ForgeStore +from edgacknowledg.forge.ingest import Ingestor +from edgacknowledg.forge.poller import sync_all +from edgacknowledg.forge.processor import Processor +from edgacknowledg.forge.sandbox import Sandbox +from edgacknowledg.forge.workspace import Workspaces + +from fakes import ( + REPOSITORY, + Clock, + FakeCommenter, + FakeForge, + FakePublisher, +) + +# Stands in for edg-rematerialize-test-run: records which files the sparse +# checkout made visible and lists the changed tests in changes.elist (unless +# tests/.edgy/no-elist exists, mimicking the real tool's current gap). +STUB_REMATERIALIZE = '''#!/usr/bin/env python3 +import argparse, os, re +from pathlib import Path +parser = argparse.ArgumentParser() +parser.add_argument('--runs-dir', type = Path) +parser.add_argument('--run-timestamp') +parser.add_argument('diff_file') +args = parser.parse_args() +home = Path(os.environ['EDG_TEST_HOME']) +run = args.runs_dir / args.run_timestamp +config_dir = run / 'edg_x86_64' +config_dir.mkdir(parents = True) +diff = open(args.diff_file).read() +pattern = r'^diff --git a/tests/tests/(\\S+) b/' +tests = sorted(set(re.findall(pattern, diff, re.M))) +if not (home / '.edgy' / 'no-elist').exists(): + (config_dir / 'changes.elist').write_text(''.join(t + '\\n' for t in tests)) +visible = sorted( + str(p.relative_to(home)) for p in home.rglob('*') + if p.is_file() and 'runs' not in p.relative_to(home).parts +) +(run / 'visible.txt').write_text('\\n'.join(visible) + '\\n') +''' + + +def git(cwd: Path, *args: str) -> str: + return subprocess.run( + ['git', *args], + cwd = cwd, + check = True, + capture_output = True, + text = True, + env = { + 'PATH': '/usr/bin:/bin', + 'HOME': str(cwd), + 'GIT_CONFIG_NOSYSTEM': '1', + 'GIT_AUTHOR_NAME': 'Test', + 'GIT_AUTHOR_EMAIL': 'test@example.com', + 'GIT_COMMITTER_NAME': 'Test', + 'GIT_COMMITTER_EMAIL': 'test@example.com', + } + ).stdout.strip() + + +class Origin: + '''A local "forge" repository with main and pull request refs.''' + + def __init__(self, path: Path) -> None: + self.path = path + path.mkdir() + git(path, 'init', '--quiet', '--initial-branch=main') + git(path, 'config', 'uploadpack.allowFilter', 'true') + git(path, 'config', 'uploadpack.allowAnySHA1InWant', 'true') + files = { + 'README': 'compiler\n', + 'src/cfe.c': 'int main(void) { return 0; }\n', + 'tests/.edgy/config.json': '{}\n', + 'tests/expectations/edg_x86_64/suite.log': 'a.sft.cpp: PASS\n', + 'tests/tests/suite/a.sft.cpp': 'int a;\n', + 'tests/tests/suite/.a.rto/default.1.1.txt': 'old output\n', + 'tests/tests/suite/b.sft.cpp': 'int b;\n', + 'tests/tests/suite/.b.rto/default.1.1.txt': 'b output\n', + 'benchmarks/baselines/project/release-c/x.bnch.cgout': + 'summary: 1000\n', + } + for index in range(40): + files[f"tests/tests/big/t{index}.sft.cpp"] = f"int t{index};\n" + self.main_sha = self.commit('main', files, 'initial') + + def commit(self, + branch: str, + files: dict[str, Optional[str]], + message: str, + parent: Optional[str] = None) -> str: + '''Commit ``files`` (None deletes) on ``branch`` (from ``parent``).''' + if parent is not None: + git(self.path, 'checkout', '--quiet', '-B', branch, parent) + else: + git(self.path, 'checkout', '--quiet', '-B', branch) + for name, content in files.items(): + path = self.path / name + if content is None: + path.unlink() + continue + path.parent.mkdir(parents = True, exist_ok = True) + path.write_text(content) + git(self.path, 'add', '--all') + git(self.path, 'commit', '--quiet', '-m', message) + git(self.path, 'checkout', '--quiet', '--detach') + return git(self.path, 'rev-parse', 'HEAD') + + def set_ref(self, ref: str, sha: str) -> None: + git(self.path, 'update-ref', ref, sha) + + def push_pull_request(self, + number: int, + files: dict[str, Optional[str]], + parent: Optional[str] = None) -> str: + sha = self.commit( + f"pr-{number}", + files, + f"pull request {number}", + parent = parent or self.main_sha + ) + self.set_ref(f"refs/pull/{number}/head", sha) + return sha + + +def pull_request_info(number: int, + head_sha: str, + base_sha: str, + *, + state: PullRequestState = PullRequestState.OPEN, + clock: Optional[Clock] = None) -> PullRequestInfo: + return PullRequestInfo( + repository = REPOSITORY, + number = number, + state = state, + title = f"Pull request {number}", + author = 'someone', + url = f"https://forge.example/{REPOSITORY}/pull/{number}", + base_ref = 'main', + base_sha = base_sha, + head_sha = head_sha, + fetch_ref = f"refs/pull/{number}/head", + updated_at = (clock or Clock())(), + ) + + +def make_test_artifact(path: Path, + tested_sha: str, + tests: list[str]) -> None: + '''A test-results-all style zip: ci-meta.json plus one config's results.''' + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr('ci-meta.json', json.dumps({'tested_sha': tested_sha})) + archive.writestr( + 'edg_x86_64/changes.elist', + ''.join(f"{test}\n" for test in tests) + ) + archive.writestr('edg_x86_64/changes.elog', 'FAIL\n') + for test in tests: + payload = io.BytesIO() + with tarfile.open(fileobj = payload, mode = 'w:gz') as tarball: + data = b'output\n' + info = tarfile.TarInfo('output.txt') + info.size = len(data) + tarball.addfile(info, io.BytesIO(data)) + stem = test.rsplit('.', 1)[0] + archive.writestr(f"edg_x86_64/{stem}.rt.tar.gz", payload.getvalue()) + + +def ci_run(head_sha: str, + *, + run_id: str = '100', + attempt: int = 1, + status: CIStatus = CIStatus.COMPLETED, + conclusion: Optional[CIConclusion] = CIConclusion.FAILURE, + artifacts: tuple[ArtifactInfo, ...] = ()) -> CIRunInfo: + return CIRunInfo( + run_id = run_id, + attempt = attempt, + head_sha = head_sha, + status = status, + conclusion = conclusion if status == CIStatus.COMPLETED else None, + completed_at = None, + url = f"https://forge.example/runs/{run_id}", + artifacts = artifacts, + ) + + +def make_tools_dir(path: Path) -> Path: + '''A frozen-tools directory holding the stub rematerialize tool.''' + (path / 'bin').mkdir(parents = True, exist_ok = True) + (path / 'pylibs').mkdir(exist_ok = True) + (path / 'bin' / 'edg-rematerialize-test-run').write_text(STUB_REMATERIALIZE) + return path + + +def make_config(home: Path, **overrides: str) -> ForgeServiceConfig: + environ = { + 'ACKNOWLEDG_FORGE_HOME': str(home), + 'ACKNOWLEDG_FORGE_TOOLS_DIR': str(make_tools_dir(home.parent / 'tools')), + 'ACKNOWLEDG_FORGE_REPOSITORIES': REPOSITORY, + 'ACKNOWLEDG_FORGE_CI_WORKFLOW': 'ci.yml', + 'ACKNOWLEDG_FORGE_COMMENTS': '1', + 'ACKNOWLEDG_FORGE_UNSANDBOXED': ( + '0' if shutil.which('bwrap') is not None else '1' + ), + } + environ.update( + {f"ACKNOWLEDG_FORGE_{key}": value for key, value in overrides.items()} + ) + return ForgeServiceConfig.from_environment(environ) + + +class Harness: + '''The service's components wired to fakes, driven step by step.''' + + def __init__(self, + store: ForgeStore, + forge: FakeForge, + config: ForgeServiceConfig, + clock: Clock, + publisher: Optional[FakePublisher] = None, + commenter: Optional[FakeCommenter] = None) -> None: + self.store = store + self.forge = forge + self.config = config + self.clock = clock + self.publisher = publisher or FakePublisher() + self.commenter = commenter or FakeCommenter() + self.wake = asyncio.Event() + config.home.mkdir(parents = True, exist_ok = True) + self.sandbox = Sandbox(enabled = not config.unsandboxed, + tools_dir = config.tools_dir) + self.workspaces = Workspaces(config.home, forge) + self.ingestor = Ingestor( + store, + forge, + config, + wake = self.wake, + clock = clock + ) + self.processor = Processor( + store = store, + forge = forge, + config = config, + workspaces = self.workspaces, + sandbox = self.sandbox, + publisher = self.publisher, + wake = self.wake, + clock = clock + ) + self.announcer = Announcer( + store = store, + forge_name = forge.name, + commenter = self.commenter, + config = config, + publisher = self.publisher, + busy = lambda: self.processor.busy_pull_requests, + clock = clock + ) + + async def start(self) -> None: + await self.announcer.start() + await self.processor.restore() + + async def step(self) -> None: + '''Poll, process until nothing is due, then announce.''' + await sync_all(self.ingestor, self.config) + for _ in range(10): + await self.processor.start_due() + if not self.processor.busy_pull_requests: + break + await self.processor.drain() + await self.announcer.sweep() + + +@pytest.fixture +def clock() -> Clock: + return Clock() + + +@pytest.fixture +def origin(tmp_path: Path) -> Origin: + return Origin(tmp_path / 'origin') + + +@pytest.fixture +def forge(origin: Origin) -> FakeForge: + return FakeForge(origin.path) + + +# Set to run the database tests against another database (e.g. +# postgresql+asyncpg://...); its schema is reset after each test. +TEST_DATABASE_URL = os.environ.get('ACKNOWLEDG_FORGE_TEST_DATABASE_URL') + + +async def _reset_database(url: str) -> None: + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + + from edgacknowledg.forge.db.models import Base + + engine = create_async_engine(url) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.drop_all) + await connection.execute(text('DROP TABLE IF EXISTS alembic_version')) + await engine.dispose() + + +@pytest_asyncio.fixture +async def store() -> AsyncIterator[ForgeStore]: + url = TEST_DATABASE_URL or 'sqlite+aiosqlite://' + if TEST_DATABASE_URL: + await _reset_database(url) + store = await ForgeStore.open(url) + yield store + await store.aclose() + if TEST_DATABASE_URL: + await _reset_database(url) + + +@pytest.fixture +def home(tmp_path: Path) -> Iterator[Path]: + yield tmp_path / 'home' diff --git a/dev_tools/services/acknowledg-forge/tests/fakes.py b/dev_tools/services/acknowledg-forge/tests/fakes.py new file mode 100644 index 0000000000..e66ca55aa3 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/fakes.py @@ -0,0 +1,169 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Test doubles for the forge, publisher, and commenter.''' + +import shutil +import uuid + +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional, Sequence + +from edgacknowledg.forge import ( + ArtifactInfo, + CIRunInfo, + PullRequestInfo, + PullRequestState, +) +from edgacknowledg.forge.db import SectionRecord +from edgacknowledg.forge.publisher import ( + PublisherUnavailableError, + ReviewIdentity, +) + +REPOSITORY = 'edg/compiler' + + +class Clock: + '''A controllable UTC clock.''' + + def __init__(self) -> None: + self.now = datetime(2026, 10, 1, 12, 0, tzinfo = timezone.utc) + + def __call__(self) -> datetime: + return self.now + + def advance(self, seconds: float) -> None: + self.now += timedelta(seconds = seconds) + + +class FakeForge: + '''A forge whose state the test sets directly; git comes from a local + origin repository. + ''' + + name = 'fake' + + def __init__(self, origin: Path) -> None: + self.origin = origin + self.pull_requests: dict[int, PullRequestInfo] = {} + self.runs: dict[str, CIRunInfo] = {} + self.artifact_files: dict[str, Path] = {} + self.downloads = 0 + self.closed = False + + def set_pull_request(self, info: PullRequestInfo) -> None: + self.pull_requests[info.number] = info + + def update_pull_request(self, number: int, **changes: object) -> None: + self.pull_requests[number] = replace( + self.pull_requests[number], + **changes # type: ignore[arg-type] + ) + + def clone_url(self, repository: str) -> str: + return f"file://{self.origin}" + + def git_http_headers(self) -> Sequence[str]: + return [] + + async def list_open_pull_requests(self, + repository: str) -> list[PullRequestInfo]: + return [ + info for info in self.pull_requests.values() + if info.state == PullRequestState.OPEN + ] + + async def get_pull_request(self, + repository: str, + number: int) -> PullRequestInfo: + return self.pull_requests[number] + + async def latest_ci_run(self, + pull_request: PullRequestInfo + ) -> Optional[CIRunInfo]: + return self.runs.get(pull_request.head_sha) + + async def download_artifact(self, + repository: str, + artifact: ArtifactInfo, + destination: Path, + *, + max_bytes: int) -> None: + self.downloads += 1 + shutil.copyfile(self.artifact_files[artifact.id], destination) + + async def aclose(self) -> None: + self.closed = True + + +class FakePublisher: + '''Records what would be served.''' + + def __init__(self) -> None: + self.reviews: dict[uuid.UUID, list[SectionRecord]] = {} + self.names: dict[uuid.UUID, str] = {} + self.available = True + self.sync_count = 0 + + async def wait_connected(self) -> None: + pass + + async def sync(self, + review: ReviewIdentity, + sections: Sequence[SectionRecord]) -> None: + if not self.available: + raise PublisherUnavailableError('fake outage') + self.sync_count += 1 + for section in sections: + assert section.root.is_dir(), section + if len(sections) == 0: + self.reviews.pop(review.review_id, None) + else: + self.reviews[review.review_id] = list(sections) + self.names[review.review_id] = review.name + + async def remove(self, review_id: uuid.UUID) -> None: + self.reviews.pop(review_id, None) + + def section_url(self, + review_id: uuid.UUID, + section: SectionRecord) -> Optional[str]: + return f"http://ui.example/review/{review_id}/{section.slug}" + + def served_names(self, review_id: uuid.UUID) -> list[str]: + return sorted(s.name for s in self.reviews.get(review_id, [])) + + +class FakeCommenter: + def __init__(self) -> None: + self.comments: list[tuple[str, int, str]] = [] + self.fail_after_post = False + + async def post_comment(self, + repository: str, + number: int, + body: str) -> str: + self.comments.append((repository, number, body)) + if self.fail_after_post: + self.fail_after_post = False + raise ConnectionError('lost the response after posting') + return str(len(self.comments)) + + async def find_comment(self, + repository: str, + number: int, + marker: str) -> Optional[str]: + for index, (comment_repository, comment_number, body) in enumerate( + self.comments): + if (comment_repository, comment_number) == (repository, number) and ( + marker in body): + return str(index + 1) + return None + + def bodies(self) -> list[str]: + return [body for _, _, body in self.comments] diff --git a/dev_tools/services/acknowledg-forge/tests/pytest.ini b/dev_tools/services/acknowledg-forge/tests/pytest.ini new file mode 100644 index 0000000000..7e9b61738b --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +testpaths = . diff --git a/dev_tools/services/acknowledg-forge/tests/run-tests.sh b/dev_tools/services/acknowledg-forge/tests/run-tests.sh new file mode 100755 index 0000000000..c86a2fefe8 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/run-tests.sh @@ -0,0 +1,14 @@ +#!/usr/bin/bash + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# Run the acknowledg-forge tests (shared core and every forge service). + +set -euo pipefail +cd "$(dirname "$0")" +exec uv run --project ../github --group dev --extra postgresql \ + --with-editable ../gitlab \ + pytest "$@" diff --git a/dev_tools/services/acknowledg-forge/tests/test_acknowledg_integration.py b/dev_tools/services/acknowledg-forge/tests/test_acknowledg_integration.py new file mode 100644 index 0000000000..99f7369612 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/test_acknowledg_integration.py @@ -0,0 +1,192 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''AcknowledgPublisher against a real AcknowlEDG server.''' + +import asyncio +import os +import shutil +import signal +import socket +import subprocess +import uuid + +from pathlib import Path +from typing import Any, AsyncIterator, Iterator + +import pytest +import pytest_asyncio +import websockets + +from edgacknowledg import decode_response, encode_message, message +from edgacknowledg.forge.db import SectionRecord +from edgacknowledg.forge.publisher import ( + KIND_TEST_CHANGES, + AcknowledgPublisher, + ReviewIdentity, +) +from edgacknowledg.forge.sandbox import Sandbox + +# services/acknowledg/server, relative to services/acknowledg-forge/tests. +SERVER_DIR = ( + Path(__file__).resolve().parent.parent.parent / 'acknowledg' / 'server' +) + +pytestmark = pytest.mark.skipif( + shutil.which('uv') is None or not SERVER_DIR.is_dir(), + reason = 'needs uv and the AcknowlEDG server sources' +) + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(('127.0.0.1', 0)) + return int(sock.getsockname()[1]) + + +class Server: + def __init__(self, port: int) -> None: + self.port = port + self.process: 'subprocess.Popen[bytes] | None' = None + + async def start(self) -> None: + self.process = subprocess.Popen( + [ + 'uv', 'run', '--quiet', '--project', str(SERVER_DIR), + 'hypercorn', '--bind', f"127.0.0.1:{self.port}", + 'acknowledg_server:app', + ], + stdout = subprocess.DEVNULL, + stderr = subprocess.DEVNULL, + start_new_session = True + ) + for _ in range(300): + try: + _, writer = await asyncio.open_connection('127.0.0.1', self.port) + writer.close() + return + except OSError: + await asyncio.sleep(0.1) + raise RuntimeError('the AcknowlEDG server did not start') + + def stop(self) -> None: + if self.process is not None: + # Hypercorn waits for open websockets on SIGTERM; don't. + os.killpg(self.process.pid, signal.SIGKILL) + self.process.wait(timeout = 30) + self.process = None + + +@pytest_asyncio.fixture +async def server() -> AsyncIterator[Server]: + server = Server(_free_port()) + await server.start() + yield server + server.stop() + + +@pytest.fixture +def section(tmp_path: Path) -> Iterator[SectionRecord]: + home = tmp_path / 'tests' + (home / 'tests' / 'suite').mkdir(parents = True) + (home / 'tests' / 'suite' / 'a.sft.cpp').write_text('int a;\n') + root = home / 'runs' / 'r1' + (root / 'edg_x86_64').mkdir(parents = True) + (root / 'edg_x86_64' / 'changes.elist').write_text('suite/a.sft.cpp\n') + yield SectionRecord( + kind = KIND_TEST_CHANGES, + name = 'Test - Changes', + slug = 'test-changes', + source_key = 'k1', + content_fingerprint = 'f1', + root = root, + home = home, + ) + + +async def _sources(port: int, review_id: uuid.UUID) -> list[Any]: + '''The section sources a new reviewer of ``review_id`` sees.''' + async with websockets.connect( + f"ws://127.0.0.1:{port}/review/{review_id}") as reviewer: + while True: + tag, value, _ = decode_response( + await asyncio.wait_for(reviewer.recv(), 10) + ) + if tag == 'review.connection-state': + return [source['slug'] for source in value['sources']] + + +async def _wait_for_sources(port: int, + review_id: uuid.UUID, + expected: list[str]) -> None: + for _ in range(100): + try: + if await _sources(port, review_id) == expected: + return + except (OSError, websockets.exceptions.WebSocketException): + # The server may still be starting. + pass + await asyncio.sleep(0.1) + assert await _sources(port, review_id) == expected + + +async def test_publish_heal_and_survive_bad_requests( + server: Server, + section: SectionRecord + ) -> None: + publisher = AcknowledgPublisher( + api_host = '127.0.0.1', + api_port = server.port, + api_key = None, + ui_host = 'ui.example', + ui_port = 443, + sandbox = Sandbox(enabled = False) + ) + task = asyncio.create_task(publisher.run()) + try: + await asyncio.wait_for(publisher.wait_connected(), 30) + review = ReviewIdentity(uuid.uuid4(), 'edg/compiler#1: test', '') + await publisher.sync(review, [section]) + await _wait_for_sources(server.port, review.review_id, ['test-changes']) + assert publisher.section_url(review.review_id, section) == ( + f"https://ui.example/review/{review.review_id}/tests/test-changes" + ) + + # A reviewer asking for a file outside the test home is refused (the + # provider logs it; a bare 'invalid' can't be routed back to the + # reviewer), and the section keeps answering afterwards. + async with websockets.connect( + f"ws://127.0.0.1:{server.port}/review/{review.review_id}" + ) as reviewer: + await reviewer.send(encode_message(message( + 'test.src.show-src', + value = {'id': '../../../../etc/passwd', 'assoc_file': None}, + section = 'test-changes' + ))) + await reviewer.send(encode_message(message( + 'test.src.list-statuses', + section = 'test-changes' + ))) + replies = [] + while 'test.src.list-statuses' not in replies: + tag, value, _ = decode_response( + await asyncio.wait_for(reviewer.recv(), 10) + ) + replies.append(tag) + assert tag != 'test.missing-src', replies + assert 'root:' not in str(value) + await _wait_for_sources(server.port, review.review_id, ['test-changes']) + + # After the server restarts (losing all state), the section is + # re-registered by the client library. + server.stop() + await server.start() + await _wait_for_sources(server.port, review.review_id, ['test-changes']) + + await publisher.sync(review, []) + await _wait_for_sources(server.port, review.review_id, []) + finally: + task.cancel() + await asyncio.gather(task, return_exceptions = True) diff --git a/dev_tools/services/acknowledg-forge/tests/test_github.py b/dev_tools/services/acknowledg-forge/tests/test_github.py new file mode 100644 index 0000000000..fa6eda40cf --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/test_github.py @@ -0,0 +1,233 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''The GitHub adapter against a mocked REST API.''' + +import hashlib +import hmac +import json + +from pathlib import Path +from typing import Any, Callable + +import httpx +import pytest + +from edgacknowledg.forge import ( + ArtifactInfo, + ArtifactTooLargeError, + CIConclusion, + CIRunTouched, + CIStatus, + PullRequestState, + PullRequestTouched, +) + +from acknowledg_forge_github import ( + GitHubClient, + GitHubCommenter, + GitHubForge, + GitHubWebhooks, + web_url_for, +) + +REPO = 'edg/compiler' +API = 'https://api.github.com' + + +def _pull(number: int, *, state: str = 'open', + merged_at: Any = None) -> dict[str, Any]: + return { + 'number': number, + 'state': state, + 'merged_at': merged_at, + 'title': f"PR {number}", + 'user': {'login': 'author'}, + 'html_url': f"https://github.com/{REPO}/pull/{number}", + 'base': {'ref': 'main', 'sha': 'b' * 40}, + 'head': {'sha': 'h' * 40}, + 'updated_at': '2026-10-01T12:00:00Z', + } + + +def _client(handler: Callable[[httpx.Request], httpx.Response], + token: str = 'secret-token') -> GitHubClient: + return GitHubClient( + api_url = API, + token = token, + transport = httpx.MockTransport(handler) + ) + + +def test_web_url_for() -> None: + assert web_url_for(API) == 'https://github.com' + assert web_url_for('https://ghe.example/api/v3') == 'https://ghe.example' + + +async def test_pull_requests_paginate_and_map_states() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers['authorization'] == 'Bearer secret-token' + if request.url.path == f"/repos/{REPO}/pulls": + if request.url.params.get('page') == '2': + return httpx.Response(200, json = [_pull(2)]) + return httpx.Response( + 200, + json = [_pull(1)], + headers = { + 'link': f"<{API}/repos/{REPO}/pulls?state=open&page=2>; rel=\"next\"" + } + ) + if request.url.path == f"/repos/{REPO}/pulls/3": + return httpx.Response(200, json = _pull( + 3, state = 'closed', merged_at = '2026-10-02T00:00:00Z' + )) + if request.url.path == f"/repos/{REPO}/pulls/4": + return httpx.Response(200, json = _pull(4, state = 'closed')) + return httpx.Response(404) + + forge = GitHubForge(_client(handler), workflow = 'ci.yml') + pulls = await forge.list_open_pull_requests(REPO) + assert [p.number for p in pulls] == [1, 2] + assert pulls[0].fetch_ref == 'refs/pull/1/head' + assert pulls[0].updated_at.tzinfo is not None + assert (await forge.get_pull_request(REPO, 3)).state == ( + PullRequestState.MERGED + ) + assert (await forge.get_pull_request(REPO, 4)).state == ( + PullRequestState.CLOSED + ) + assert forge.clone_url(REPO) == f"https://github.com/{REPO}.git" + assert forge.git_http_headers()[0].startswith('Authorization: Basic ') + await forge.aclose() + + +async def test_latest_ci_run_with_artifacts() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == f"/repos/{REPO}/actions/workflows/ci.yml/runs": + assert request.url.params['head_sha'] == 'h' * 40 + assert request.url.params['event'] == 'pull_request' + return httpx.Response(200, json = {'workflow_runs': [{ + 'id': 77, 'run_attempt': 2, 'head_sha': 'h' * 40, + 'status': 'completed', 'conclusion': 'failure', + 'updated_at': '2026-10-01T13:00:00Z', + 'html_url': 'https://github.com/runs/77', + }]}) + if path == f"/repos/{REPO}/actions/runs/77/artifacts": + return httpx.Response(200, json = {'artifacts': [{ + 'id': 5, 'name': 'test-results-all', 'size_in_bytes': 10, + 'digest': 'sha256:abc', 'expired': False, + }]}) + return httpx.Response(404) + + forge = GitHubForge(_client(handler), workflow = 'ci.yml') + pull = forge._pull_request(REPO, _pull(1)) + run = await forge.latest_ci_run(pull) + assert run is not None + assert (run.run_id, run.attempt) == ('77', 2) + assert run.status == CIStatus.COMPLETED + assert run.conclusion == CIConclusion.FAILURE + artifact = run.artifact('test-results-all') + assert artifact is not None and artifact.digest == 'sha256:abc' + + untracked = GitHubForge(_client(handler), workflow = None) + assert await untracked.latest_ci_run(pull) is None + + +async def test_artifact_download_does_not_leak_token(tmp_path: Path) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == 'api.github.com': + return httpx.Response( + 302, + headers = {'location': 'https://blobs.example/artifact.zip?sig=1'} + ) + assert 'authorization' not in request.headers + return httpx.Response(200, content = b'PK' + b'0' * 100) + + forge = GitHubForge(_client(handler), workflow = 'ci.yml') + artifact = ArtifactInfo('test-results-all', '5', 102, None, False) + destination = tmp_path / 'a.zip' + await forge.download_artifact(REPO, artifact, destination, max_bytes = 1000) + assert destination.stat().st_size == 102 + + with pytest.raises(ArtifactTooLargeError): + await forge.download_artifact( + REPO, + ArtifactInfo('test-results-all', '5', 10, None, False), + tmp_path / 'b.zip', + max_bytes = 50 + ) + + +async def test_rate_limit_is_waited_out(monkeypatch: pytest.MonkeyPatch + ) -> None: + sleeps: list[float] = [] + + async def fake_sleep(seconds: float) -> None: + sleeps.append(seconds) + + monkeypatch.setattr('asyncio.sleep', fake_sleep) + calls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + if calls == 1: + return httpx.Response(403, headers = {'retry-after': '7'}) + return httpx.Response(200, json = _pull(1)) + + forge = GitHubForge(_client(handler), workflow = None) + assert (await forge.get_pull_request(REPO, 1)).number == 1 + assert sleeps == [7.0] + + +def test_webhooks_verify_and_parse() -> None: + webhooks = GitHubWebhooks('hook-secret') + body = json.dumps({ + 'number': 9, + 'repository': {'full_name': REPO}, + }).encode() + signature = 'sha256=' + hmac.new( + b'hook-secret', body, hashlib.sha256 + ).hexdigest() + assert webhooks.verify({'x-hub-signature-256': signature}, body) + assert not webhooks.verify({'x-hub-signature-256': 'sha256=00'}, body) + assert not webhooks.verify({}, body) + + assert webhooks.parse({'x-github-event': 'pull_request'}, body) == [ + PullRequestTouched(REPO, 9) + ] + run_body = json.dumps({ + 'workflow_run': {'head_sha': 'h' * 40}, + 'repository': {'full_name': REPO}, + }).encode() + assert webhooks.parse({'x-github-event': 'workflow_run'}, run_body) == [ + CIRunTouched(REPO, 'h' * 40) + ] + assert webhooks.parse({'x-github-event': 'ping'}, b'{}') == [] + + +async def test_commenter_finds_only_its_own_marked_comment() -> None: + posted: list[Any] = [] + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path == '/user': + return httpx.Response(200, json = {'login': 'acknowledg-bot'}) + if path == f"/repos/{REPO}/issues/1/comments": + if request.method == 'POST': + posted.append(json.loads(request.content)) + return httpx.Response(201, json = {'id': 42}) + return httpx.Response(200, json = [ + {'id': 1, 'user': {'login': 'someone'}, 'body': 'MARK'}, + {'id': 2, 'user': {'login': 'acknowledg-bot'}, 'body': 'x MARK y'}, + ]) + return httpx.Response(404) + + commenter = GitHubCommenter(_client(handler)) + assert await commenter.post_comment(REPO, 1, 'hello') == '42' + assert posted == [{'body': 'hello'}] + assert await commenter.find_comment(REPO, 1, 'MARK') == '2' + assert await commenter.find_comment(REPO, 1, 'OTHER') is None diff --git a/dev_tools/services/acknowledg-forge/tests/test_gitlab.py b/dev_tools/services/acknowledg-forge/tests/test_gitlab.py new file mode 100644 index 0000000000..aa823e4480 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/test_gitlab.py @@ -0,0 +1,224 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''The GitLab adapter against a mocked REST API (v4).''' + +import json + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + +import httpx + +from edgacknowledg.forge import ( + ArtifactInfo, + CIConclusion, + CIRunTouched, + CIStatus, + PullRequestState, + PullRequestTouched, +) + +from acknowledg_forge_gitlab import ( + GitLabClient, + GitLabCommenter, + GitLabForge, + GitLabWebhooks, + web_url_for, +) + +PROJECT = 'edg/front-end' +ENCODED = 'edg%2Ffront-end' +API = 'https://gitlab.example/api/v4' +NOW = datetime(2026, 10, 1, 12, 0, tzinfo = timezone.utc) + + +def _merge_request(iid: int, state: str = 'opened') -> dict[str, Any]: + return { + 'iid': iid, + 'state': state, + 'title': f"MR {iid}", + 'author': {'username': 'author'}, + 'web_url': f"https://gitlab.example/{PROJECT}/-/merge_requests/{iid}", + 'target_branch': 'main', + 'sha': 'h' * 40, + 'diff_refs': {'base_sha': 'b' * 40}, + 'updated_at': '2026-10-01T12:00:00.000Z', + } + + +def _forge(handler: Callable[[httpx.Request], httpx.Response], + pipeline: str = 'ci') -> GitLabForge: + client = GitLabClient( + api_url = API, + token = 'glpat-secret', + transport = httpx.MockTransport(handler) + ) + return GitLabForge( + client, + pipeline = pipeline, + artifact_jobs = ('test-results-all', 'bench-results'), + clock = lambda: NOW + ) + + +def test_web_url_for() -> None: + assert web_url_for(API) == 'https://gitlab.example' + + +async def test_merge_requests_map_states_and_paths() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers['private-token'] == 'glpat-secret' + # Project paths are URL-encoded into one path segment. + path = request.url.raw_path.decode().split('?')[0] + if path == f"/api/v4/projects/{ENCODED}/merge_requests": + assert request.url.params['state'] == 'opened' + return httpx.Response(200, json = [_merge_request(1)]) + if path == f"/api/v4/projects/{ENCODED}/merge_requests/2": + return httpx.Response(200, json = _merge_request(2, 'merged')) + if path == f"/api/v4/projects/{ENCODED}/merge_requests/3": + return httpx.Response(200, json = _merge_request(3, 'locked')) + return httpx.Response(404) + + forge = _forge(handler) + [first] = await forge.list_open_pull_requests(PROJECT) + assert first.fetch_ref == 'refs/merge-requests/1/head' + assert first.base_ref == 'main' and first.head_sha == 'h' * 40 + assert (await forge.get_pull_request(PROJECT, 2)).state == ( + PullRequestState.MERGED + ) + assert (await forge.get_pull_request(PROJECT, 3)).state == ( + PullRequestState.CLOSED + ) + assert forge.clone_url(PROJECT) == f"https://gitlab.example/{PROJECT}.git" + await forge.aclose() + + +async def test_pipeline_with_job_artifacts() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.raw_path.decode().split('?')[0] + if path == f"/api/v4/projects/{ENCODED}/pipelines": + assert request.url.params['sha'] == 'h' * 40 + assert request.url.params['name'] == 'ci' + return httpx.Response(200, json = [{ + 'id': 900, 'sha': 'h' * 40, 'status': 'failed', + 'updated_at': '2026-10-01T13:00:00Z', + 'web_url': 'https://gitlab.example/pipelines/900', + }]) + if path == f"/api/v4/projects/{ENCODED}/pipelines/900/jobs": + return httpx.Response(200, json = [ + {'id': 1, 'name': 'build', 'artifacts': [ + {'file_type': 'archive', 'size': 5}, + ]}, + {'id': 2, 'name': 'test-results-all', + 'artifacts_expire_at': '2026-10-10T00:00:00Z', 'artifacts': [ + {'file_type': 'archive', 'size': 10}, + {'file_type': 'trace', 'size': 99}, + ]}, + {'id': 3, 'name': 'bench-results', + 'artifacts_expire_at': '2026-09-01T00:00:00Z', 'artifacts': [ + {'file_type': 'archive', 'size': 20}, + ]}, + ]) + return httpx.Response(404) + + forge = _forge(handler) + pull = forge._merge_request(PROJECT, _merge_request(1)) + run = await forge.latest_ci_run(pull) + assert run is not None + assert run.status == CIStatus.COMPLETED + assert run.conclusion == CIConclusion.FAILURE + assert [a.name for a in run.artifacts] == [ + 'test-results-all', 'bench-results' + ] + tests = run.artifact('test-results-all') + assert tests is not None and tests.id == '2' and tests.size == 10 + assert not tests.expired + bench = run.artifact('bench-results') + assert bench is not None and bench.expired + + +async def test_running_pipeline_has_no_artifacts() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert 'name' not in request.url.params + return httpx.Response(200, json = [{ + 'id': 901, 'sha': 'h' * 40, 'status': 'running', + 'updated_at': '2026-10-01T13:00:00Z', 'web_url': 'u', + }]) + + forge = _forge(handler, pipeline = '*') + run = await forge.latest_ci_run( + forge._merge_request(PROJECT, _merge_request(1)) + ) + assert run is not None and run.status == CIStatus.RUNNING + assert run.artifacts == () + + +async def test_job_artifact_download(tmp_path: Path) -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.raw_path.decode() + assert path == f"/api/v4/projects/{ENCODED}/jobs/2/artifacts" + return httpx.Response(200, content = b'PK-zip') + + forge = _forge(handler) + destination = tmp_path / 'a.zip' + await forge.download_artifact( + PROJECT, + ArtifactInfo('test-results-all', '2', 6, None, False), + destination, + max_bytes = 100 + ) + assert destination.read_bytes() == b'PK-zip' + + +def test_webhooks_verify_and_parse() -> None: + webhooks = GitLabWebhooks('hook-secret') + assert webhooks.verify({'x-gitlab-token': 'hook-secret'}, b'') + assert not webhooks.verify({'x-gitlab-token': 'nope'}, b'') + assert not webhooks.verify({}, b'') + + merge_body = json.dumps({ + 'project': {'path_with_namespace': PROJECT}, + 'object_attributes': {'iid': 4}, + }).encode() + assert webhooks.parse({'x-gitlab-event': 'Merge Request Hook'}, + merge_body) == [PullRequestTouched(PROJECT, 4)] + pipeline_body = json.dumps({ + 'project': {'path_with_namespace': PROJECT}, + 'object_attributes': {'sha': 'h' * 40}, + 'merge_request': {'iid': 4}, + }).encode() + assert webhooks.parse({'x-gitlab-event': 'Pipeline Hook'}, + pipeline_body) == [ + CIRunTouched(PROJECT, 'h' * 40), + PullRequestTouched(PROJECT, 4), + ] + assert webhooks.parse({'x-gitlab-event': 'Push Hook'}, b'{}') == [] + + +async def test_commenter_uses_merge_request_notes() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.raw_path.decode().split('?')[0] + if path == '/api/v4/user': + return httpx.Response(200, json = {'username': 'acknowledg-bot'}) + if path == f"/api/v4/projects/{ENCODED}/merge_requests/4/notes": + if request.method == 'POST': + assert json.loads(request.content) == {'body': 'hi'} + return httpx.Response(201, json = {'id': 7}) + return httpx.Response(200, json = [ + {'id': 5, 'author': {'username': 'other'}, 'body': 'MARK'}, + {'id': 6, 'author': {'username': 'acknowledg-bot'}, 'body': 'MARK'}, + ]) + return httpx.Response(404) + + client = GitLabClient( + api_url = API, + token = 't', + transport = httpx.MockTransport(handler) + ) + commenter = GitLabCommenter(client) + assert await commenter.post_comment(PROJECT, 4, 'hi') == '7' + assert await commenter.find_comment(PROJECT, 4, 'MARK') == '6' diff --git a/dev_tools/services/acknowledg-forge/tests/test_lifecycle.py b/dev_tools/services/acknowledg-forge/tests/test_lifecycle.py new file mode 100644 index 0000000000..b49e367b47 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/test_lifecycle.py @@ -0,0 +1,227 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''End-to-end pull request lifecycle against a fake forge.''' + +from pathlib import Path + +from edgacknowledg.forge import ( + ArtifactInfo, + CIConclusion, + CIStatus, + PullRequestState, +) +from edgacknowledg.forge.db import ForgeStore + +from conftest import ( + Harness, + Origin, + ci_run, + make_config, + make_test_artifact, + pull_request_info, +) +from fakes import REPOSITORY, Clock, FakeForge + +CHANGES = 'Test - Changes' +FAILURES = 'Test - Failures' + +PR_FILES = { + 'tests/tests/suite/.a.rto/default.1.1.txt': 'new output\n', + 'tests/expectations/edg_x86_64/suite.log': 'a.sft.cpp: FAIL\n', +} + + +async def _review_id(store: ForgeStore, number: int) -> object: + record = await store.find_pull_request('fake', REPOSITORY, number) + assert record is not None + return record.review_id + + +async def test_motivating_example(tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + config = make_config(tmp_path / 'home') + harness = Harness(store, forge, config, clock) + await harness.start() + + # A new pull request: Test - Changes is served; CI hasn't run, so the + # pull request hasn't settled and nothing is announced yet. + head1 = origin.push_pull_request(1, PR_FILES) + forge.set_pull_request(pull_request_info(1, head1, origin.main_sha)) + await harness.step() + review_id = await _review_id(store, 1) + assert harness.publisher.served_names(review_id) == [CHANGES] + assert harness.commenter.comments == [] + + # The base worktree only has what's needed: the touched test, not its + # neighbours or the big unrelated directory. + changes = harness.publisher.reviews[review_id][0] + visible = (changes.root / 'visible.txt').read_text().split() + assert 'tests/suite/a.sft.cpp' in visible + assert 'tests/suite/.a.rto/default.1.1.txt' in visible + assert not any(path.startswith('tests/big/') for path in visible) + assert 'expectations/edg_x86_64/suite.log' in visible + + # CI finishes with unexpected differences: Test - Failures is added, and + # both sections are announced in a single comment. + artifact_zip = tmp_path / 'artifact-1.zip' + make_test_artifact(artifact_zip, head1, ['suite/b.sft.cpp']) + forge.artifact_files['a1'] = artifact_zip + forge.runs[head1] = ci_run(head1, artifacts = ( + ArtifactInfo('test-results-all', 'a1', 100, 'sha256:aaa', False), + )) + await harness.step() + assert harness.publisher.served_names(review_id) == [CHANGES, FAILURES] + assert len(harness.commenter.comments) == 1 + body = harness.commenter.bodies()[0] + assert f"[{CHANGES}](" in body and f"[{FAILURES}](" in body + assert 'CI [failure]' in body + + # The failures were extracted into the head worktree, which has the + # flagged test checked out. + failures = next( + s for s in harness.publisher.reviews[review_id] if s.name == FAILURES + ) + assert (failures.root / 'edg_x86_64' / 'changes.elist').is_file() + assert (failures.home / 'tests' / 'suite' / 'b.sft.cpp').is_file() + assert not (failures.home / 'tests' / 'big').exists() + + # The author pushes again: Test - Changes is rebuilt; while CI runs, the + # old failures stay up and nothing is announced. + head2 = origin.push_pull_request( + 1, + {'tests/tests/suite/.a.rto/default.1.1.txt': 'newer output\n'}, + parent = head1 + ) + forge.update_pull_request(1, head_sha = head2) + forge.runs[head2] = ci_run(head2, run_id = '101', status = CIStatus.RUNNING) + await harness.step() + assert harness.publisher.served_names(review_id) == [CHANGES, FAILURES] + new_changes = next( + s for s in harness.publisher.reviews[review_id] if s.name == CHANGES + ) + assert new_changes.content_fingerprint != changes.content_fingerprint + assert not changes.root.exists(), 'superseded run dir was removed' + assert len(harness.commenter.comments) == 1 + + # CI passes: failures are removed, announced in one more comment. + forge.runs[head2] = ci_run( + head2, + run_id = '101', + conclusion = CIConclusion.SUCCESS + ) + await harness.step() + assert harness.publisher.served_names(review_id) == [CHANGES] + assert len(harness.commenter.comments) == 2 + body = harness.commenter.bodies()[1] + assert f"~~{FAILURES}~~" in body and '(updated)' in body + assert not failures.root.exists() + + # A restart restores the same state and says nothing. + restarted = Harness(store, forge, config, clock, commenter = + harness.commenter) + await restarted.start() + assert restarted.publisher.served_names(review_id) == [CHANGES] + await restarted.step() + assert len(harness.commenter.comments) == 2 + + # Merging tears everything down without a comment, including the clone + # (no other open pull request needs it). + forge.update_pull_request(1, state = PullRequestState.MERGED) + await restarted.step() + assert restarted.publisher.served_names(review_id) == [] + assert not restarted.workspaces.review_dir(review_id).exists() + assert not restarted.workspaces.clone_dir(REPOSITORY).exists() + assert len(harness.commenter.comments) == 2 + + # Reopening reuses the review id; the same results mean no comment. + forge.update_pull_request(1, state = PullRequestState.OPEN) + await restarted.step() + assert await _review_id(store, 1) == review_id + assert restarted.publisher.served_names(review_id) == [CHANGES] + assert len(harness.commenter.comments) == 2 + + +async def test_clone_shared_until_last_pull_request_closes( + tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + config = make_config(tmp_path / 'home') + harness = Harness(store, forge, config, clock) + await harness.start() + + head1 = origin.push_pull_request(1, PR_FILES) + head2 = origin.push_pull_request(2, { + 'tests/tests/suite/.b.rto/default.1.1.txt': 'changed\n', + }) + forge.set_pull_request(pull_request_info(1, head1, origin.main_sha)) + forge.set_pull_request(pull_request_info(2, head2, origin.main_sha)) + await harness.step() + review1 = await _review_id(store, 1) + review2 = await _review_id(store, 2) + assert harness.publisher.served_names(review1) == [CHANGES] + assert harness.publisher.served_names(review2) == [CHANGES] + + forge.update_pull_request(1, state = PullRequestState.CLOSED) + await harness.step() + assert not harness.workspaces.review_dir(review1).exists() + assert harness.workspaces.review_dir(review2).exists() + assert harness.workspaces.clone_dir(REPOSITORY).exists() + + forge.update_pull_request(2, state = PullRequestState.CLOSED) + await harness.step() + assert not harness.workspaces.clone_dir(REPOSITORY).exists() + + +async def test_open_pull_request_without_sections_keeps_clone( + tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + config = make_config(tmp_path / 'home') + harness = Harness(store, forge, config, clock) + await harness.start() + + # Pull request 1 changes no tests, so it has no worktree at all. + head1 = origin.push_pull_request(1, {'README': 'docs only\n'}) + head2 = origin.push_pull_request(2, PR_FILES) + forge.set_pull_request(pull_request_info(1, head1, origin.main_sha)) + forge.set_pull_request(pull_request_info(2, head2, origin.main_sha)) + await harness.step() + assert harness.publisher.served_names(await _review_id(store, 1)) == [] + + forge.update_pull_request(2, state = PullRequestState.CLOSED) + await harness.step() + assert harness.workspaces.clone_dir(REPOSITORY).exists() + + +async def test_missing_elist_serves_no_changes_section( + tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + config = make_config(tmp_path / 'home') + harness = Harness(store, forge, config, clock) + await harness.start() + origin.main_sha = origin.commit( + 'main', + {'tests/.edgy/no-elist': ''}, + 'mimic the real tool', + parent = origin.main_sha + ) + head = origin.push_pull_request(1, PR_FILES) + forge.set_pull_request(pull_request_info(1, head, origin.main_sha)) + await harness.step() + review_id = await _review_id(store, 1) + assert harness.publisher.served_names(review_id) == [] + record = await store.find_pull_request('fake', REPOSITORY, 1) + assert record is not None and not record.is_dirty diff --git a/dev_tools/services/acknowledg-forge/tests/test_safety.py b/dev_tools/services/acknowledg-forge/tests/test_safety.py new file mode 100644 index 0000000000..847cf7df44 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/test_safety.py @@ -0,0 +1,221 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Handling of untrusted input: archives, paths, and sandboxed commands.''' + +import io +import os +import shutil +import stat +import tarfile +import zipfile + +from pathlib import Path, PurePosixPath + +import pytest + +from edgacknowledg.forge.sandbox import Sandbox +from edgacknowledg.forge.workspace import ( + UnsafeArchiveError, + WorkspaceError, + changes_patterns, + ensure_real_directory, + extract_artifact, + failures_patterns, + patterns_for_test, +) + +needs_bwrap = pytest.mark.skipif( + shutil.which('bwrap') is None, + reason = 'bubblewrap is not installed' +) + + +def _zip(path: Path, entries: dict[str, bytes]) -> Path: + with zipfile.ZipFile(path, 'w') as archive: + for name, data in entries.items(): + archive.writestr(name, data) + return path + + +@pytest.mark.parametrize('name', [ + '../escape.txt', + '/absolute.txt', + 'a/../../escape.txt', + 'a\\..\\escape.txt', +]) +def test_extract_rejects_escaping_paths(tmp_path: Path, name: str) -> None: + archive = _zip(tmp_path / 'a.zip', {name: b'x'}) + destination = tmp_path / 'out' + destination.mkdir() + with pytest.raises(UnsafeArchiveError): + extract_artifact(archive, destination, max_bytes = 1024) + assert not (tmp_path / 'escape.txt').exists() + + +def test_extract_rejects_symlinks(tmp_path: Path) -> None: + path = tmp_path / 'a.zip' + with zipfile.ZipFile(path, 'w') as archive: + info = zipfile.ZipInfo('link') + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(info, '/etc/passwd') + destination = tmp_path / 'out' + destination.mkdir() + with pytest.raises(UnsafeArchiveError): + extract_artifact(path, destination, max_bytes = 1024) + + +def test_extract_caps_total_size(tmp_path: Path) -> None: + # Highly compressible, so the zip itself is tiny. + archive = _zip(tmp_path / 'a.zip', {'big.txt': b'0' * 100_000}) + destination = tmp_path / 'out' + destination.mkdir() + with pytest.raises(UnsafeArchiveError): + extract_artifact(archive, destination, max_bytes = 10_000) + + +def test_extract_caps_inner_tarballs(tmp_path: Path) -> None: + payload = io.BytesIO() + with tarfile.open(fileobj = payload, mode = 'w:gz') as tarball: + data = b'0' * 100_000 + info = tarfile.TarInfo('output.txt') + info.size = len(data) + tarball.addfile(info, io.BytesIO(data)) + archive = _zip(tmp_path / 'a.zip', {'cfg/t.rt.tar.gz': payload.getvalue()}) + destination = tmp_path / 'out' + destination.mkdir() + with pytest.raises(UnsafeArchiveError): + extract_artifact(archive, destination, max_bytes = 10_000) + + +def test_extract_valid_artifact(tmp_path: Path) -> None: + archive = _zip(tmp_path / 'a.zip', { + 'ci-meta.json': b'{}', + 'cfg/changes.elist': b'suite/a.sft.cpp\n', + }) + destination = tmp_path / 'out' + destination.mkdir() + extract_artifact(archive, destination, max_bytes = 10_000) + assert (destination / 'cfg' / 'changes.elist').read_bytes() == ( + b'suite/a.sft.cpp\n' + ) + + +def test_ensure_real_directory_refuses_symlinks(tmp_path: Path) -> None: + outside = tmp_path / 'outside' + outside.mkdir() + root = tmp_path / 'worktree' + (root / 'tests').mkdir(parents = True) + # What a pull request could commit in place of tests/runs. + os.symlink(outside, root / 'tests' / 'runs') + with pytest.raises(WorkspaceError): + ensure_real_directory(root, PurePosixPath('tests', 'runs', 'ci-1')) + assert list(outside.iterdir()) == [] + + +def test_test_patterns() -> None: + assert patterns_for_test(PurePosixPath('tests/tests/s/d/a.sft.cpp')) == [ + '/tests/tests/s/d/a.sft.cpp', + '/tests/tests/s/d/.a.sft.rto/', + '/tests/tests/s/d/.a.rto/', + ] + # Multi-file tests own their directory. + mft_test = PurePosixPath('tests/tests/cwg/x/test.mft.cpp') + assert patterns_for_test(mft_test) == ['/tests/tests/cwg/x/'] + # Pattern characters in names are escaped. + assert patterns_for_test(PurePosixPath('tests/tests/s/[x].sft.c'))[0] == ( + '/tests/tests/s/\\[x\\].sft.c' + ) + + +def test_changes_patterns_cover_recordings_and_expectations() -> None: + diff = '\n'.join([ + 'diff --git a/tests/tests/s/.a.rto/default.1.1.txt ' + 'b/tests/tests/s/.a.rto/default.1.1.txt', + '--- a/tests/tests/s/.a.rto/default.1.1.txt', + '+++ b/tests/tests/s/.a.rto/default.1.1.txt', + '-old', + '+new', + 'diff --git a/tests/expectations/edg_x86_64/other.log ' + 'b/tests/expectations/edg_x86_64/other.log', + '--- a/tests/expectations/edg_x86_64/other.log', + '+++ b/tests/expectations/edg_x86_64/other.log', + '-dir/b.sft.cpp: PASS', + '+dir/b.sft.cpp: FAIL', + ]) + patterns = changes_patterns(diff) + assert '/tests/tests/s/.a.rto/' in patterns + assert '/tests/tests/s/a.*' in patterns + assert '/tests/tests/other/dir/b.sft.cpp' in patterns + # Tooling comes from the frozen tools directory, never the checkout. + assert not any(p.startswith('/dev_tools/') for p in patterns) + + +def test_failures_patterns_ignore_escaping_ids() -> None: + patterns = failures_patterns(['s/a.sft.cpp', '../../etc/passwd']) + assert '/tests/tests/s/a.sft.cpp' in patterns + assert not any('etc' in pattern for pattern in patterns) + + +@needs_bwrap +async def test_sandbox_confines_writes_and_network(tmp_path: Path) -> None: + readable = tmp_path / 'readable' + readable.mkdir() + (readable / 'input.txt').write_text('data\n') + writable = tmp_path / 'writable' + writable.mkdir() + secret = tmp_path / 'secret.txt' + secret.write_text('token\n') + + sandbox = Sandbox(enabled = True) + await sandbox.check() + script = ( + f"cat {readable}/input.txt > {writable}/copy.txt; " + f"echo x > {readable}/tampered.txt 2>/dev/null && echo WROTE-RO; " + f"cat {secret} 2>/dev/null && echo READ-SECRET; " + f"echo \"env=$EDG_SECRET\"" + ) + os.environ['EDG_SECRET'] = 'leaked' + try: + result = await sandbox.run( + ['sh', '-c', script], + read_only = [readable], + writable = [writable], + timeout = 30 + ) + finally: + del os.environ['EDG_SECRET'] + assert result.succeeded, result.output + assert (writable / 'copy.txt').read_text() == 'data\n' + assert 'WROTE-RO' not in result.output + assert 'READ-SECRET' not in result.output + assert 'env=\n' in result.output + + +async def test_sandbox_timeout_kills(tmp_path: Path) -> None: + sandbox = Sandbox(enabled = shutil.which('bwrap') is not None) + result = await sandbox.run(['sleep', '30'], timeout = 0.5) + assert result.timed_out and not result.succeeded + + +@needs_bwrap +async def test_sandbox_provides_frozen_tools(tmp_path: Path) -> None: + tools = tmp_path / 'tools' + (tools / 'bin').mkdir(parents = True) + (tools / 'pylibs' / 'frozenlib').mkdir(parents = True) + (tools / 'pylibs' / 'frozenlib' / '__init__.py').write_text("NAME = 'ok'\n") + script = tools / 'bin' / 'frozen-tool' + script.write_text( + '#!/usr/bin/env python3\nimport frozenlib\nprint(frozenlib.NAME)\n' + ) + script.chmod(0o755) + sandbox = Sandbox(enabled = True, tools_dir = tools) + # Found on PATH, importing from the frozen pylibs, without binding the + # tools explicitly. + result = await sandbox.run(['frozen-tool'], timeout = 30) + assert result.succeeded and result.output == 'ok\n', result.output + # ...and read-only. + result = await sandbox.run(['touch', str(script)], timeout = 30) + assert not result.succeeded diff --git a/dev_tools/services/acknowledg-forge/tests/test_state.py b/dev_tools/services/acknowledg-forge/tests/test_state.py new file mode 100644 index 0000000000..25a0c68d7e --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/test_state.py @@ -0,0 +1,297 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Database semantics, retries, announcements, and webhooks.''' + +import hashlib +import hmac +import json +import zipfile + +from pathlib import Path + +from edgacknowledg.forge import ( + ArtifactInfo, + CIConclusion, + PullRequestTouched, + CIRunTouched, +) +from edgacknowledg.forge.db import ForgeStore +from edgacknowledg.forge.webhooks import EventQueue, create_app + +from conftest import ( + Harness, + Origin, + ci_run, + make_config, + make_test_artifact, + pull_request_info, +) +from fakes import REPOSITORY, Clock, FakeForge + +PR_FILES = { + 'tests/tests/suite/.a.rto/default.1.1.txt': 'new output\n', +} + + +async def test_generation_tracks_material_changes(store: ForgeStore, + clock: Clock) -> None: + info = pull_request_info(1, 'a' * 40, 'b' * 40, clock = clock) + assert await store.upsert_pull_request( + 'fake', info, now = clock(), settle_timeout = 60 + ) + assert not await store.upsert_pull_request( + 'fake', info, now = clock(), settle_timeout = 60 + ) + # The base branch moving isn't material. + moved = pull_request_info(1, 'a' * 40, 'c' * 40, clock = clock) + assert not await store.upsert_pull_request( + 'fake', moved, now = clock(), settle_timeout = 60 + ) + record = await store.find_pull_request('fake', REPOSITORY, 1) + assert record is not None and record.generation == 1 + + # Processing that started at generation 1 doesn't hide a later change. + pushed = pull_request_info(1, 'd' * 40, 'c' * 40, clock = clock) + assert await store.upsert_pull_request( + 'fake', pushed, now = clock(), settle_timeout = 60 + ) + await store.mark_processed(record.id, 1) + record = await store.get_pull_request(record.id) + assert record is not None and record.is_dirty + + +async def test_failures_back_off_then_give_up(store: ForgeStore, + clock: Clock) -> None: + info = pull_request_info(1, 'a' * 40, 'b' * 40, clock = clock) + await store.upsert_pull_request( + 'fake', info, now = clock(), settle_timeout = 60 + ) + record = await store.find_pull_request('fake', REPOSITORY, 1) + assert record is not None + for attempt in range(3): + gave_up = await store.mark_failed( + record.id, record.generation, 'boom', + now = clock(), max_attempts = 3, base_delay = 60, max_delay = 3600 + ) + assert gave_up == (attempt == 2) + assert await store.due_pull_requests('fake', clock()) == [] + + # A new push makes it due again immediately. + pushed = pull_request_info(1, 'c' * 40, 'b' * 40, clock = clock) + await store.upsert_pull_request( + 'fake', pushed, now = clock(), settle_timeout = 60 + ) + assert len(await store.due_pull_requests('fake', clock())) == 1 + + +async def test_publisher_outage_defers_without_counting( + tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + harness = Harness(store, forge, make_config(tmp_path / 'home'), clock) + await harness.start() + head = origin.push_pull_request(1, PR_FILES) + forge.set_pull_request(pull_request_info(1, head, origin.main_sha)) + harness.publisher.available = False + await harness.step() + record = await store.find_pull_request('fake', REPOSITORY, 1) + assert record is not None and record.is_dirty and record.error_count == 0 + + harness.publisher.available = True + await harness.step() + assert harness.publisher.served_names(record.review_id) == [ + 'Test - Changes' + ] + + +async def test_settle_deadline_announces_without_ci(tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + config = make_config(tmp_path / 'home', SETTLE_TIMEOUT = '600') + harness = Harness(store, forge, config, clock) + await harness.start() + head = origin.push_pull_request(1, PR_FILES) + forge.set_pull_request(pull_request_info(1, head, origin.main_sha)) + await harness.step() + assert harness.commenter.comments == [] + + # No new event at all; the deadline passing is enough. + clock.advance(601) + await harness.announcer.sweep() + assert len(harness.commenter.comments) == 1 + + +async def test_comment_not_duplicated_after_crash(tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + config = make_config(tmp_path / 'home', CI_WORKFLOW = '') + harness = Harness(store, forge, config, clock) + await harness.start() + head = origin.push_pull_request(1, PR_FILES) + forge.set_pull_request(pull_request_info(1, head, origin.main_sha)) + + # The comment is posted but the response is lost. + harness.commenter.fail_after_post = True + await harness.step() + assert len(harness.commenter.comments) == 1 + + # The retry finds the posted comment instead of posting another. + await harness.announcer.sweep() + assert len(harness.commenter.comments) == 1 + record = await store.find_pull_request('fake', REPOSITORY, 1) + assert record is not None + announcement = await store.announcement(record.id) + assert announcement is not None and announcement.last_comment_id == '1' + assert announcement.pending_fingerprint is None + + +async def test_fresh_database_adopts_existing_pull_requests( + tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + config = make_config(tmp_path / 'home', CI_WORKFLOW = '') + head1 = origin.push_pull_request(1, PR_FILES) + forge.set_pull_request(pull_request_info(1, head1, origin.main_sha, + clock = clock)) + clock.advance(60) + + harness = Harness(store, forge, config, clock) + await harness.start() + await harness.step() + # Already open (and last updated) before the service started: adopted. + assert harness.commenter.comments == [] + + # Activity after the start is announced as usual. + clock.advance(60) + head2 = origin.push_pull_request(2, PR_FILES) + forge.set_pull_request(pull_request_info(2, head2, origin.main_sha, + clock = clock)) + await harness.step() + assert len(harness.commenter.comments) == 1 + assert harness.commenter.comments[0][1] == 2 + + +async def test_rerun_with_same_run_id_replaces_failures( + tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + harness = Harness(store, forge, make_config(tmp_path / 'home'), clock) + await harness.start() + head = origin.push_pull_request(1, PR_FILES) + forge.set_pull_request(pull_request_info(1, head, origin.main_sha)) + zip1 = tmp_path / 'one.zip' + make_test_artifact(zip1, head, ['suite/b.sft.cpp']) + forge.artifact_files['a1'] = zip1 + forge.runs[head] = ci_run(head, artifacts = ( + ArtifactInfo('test-results-all', 'a1', 1, None, False), + )) + await harness.step() + record = await store.find_pull_request('fake', REPOSITORY, 1) + assert record is not None + assert 'Test - Failures' in harness.publisher.served_names(record.review_id) + + # Re-run attempt 2 of the same run passes. + forge.runs[head] = ci_run( + head, attempt = 2, conclusion = CIConclusion.SUCCESS, + artifacts = (ArtifactInfo('test-results-all', 'a1', 1, None, False),) + ) + await harness.step() + assert harness.publisher.served_names(record.review_id) == [ + 'Test - Changes' + ] + + +async def test_event_queue_deduplicates() -> None: + queue = EventQueue() + queue.put(PullRequestTouched(REPOSITORY, 1)) + queue.put(PullRequestTouched(REPOSITORY, 1)) + queue.put(CIRunTouched(REPOSITORY, 'a' * 40)) + assert queue._queue.qsize() == 2 + + +class _SignedWebhooks: + '''A minimal ForgeWebhooks: HMAC-SHA256 of the body.''' + + def __init__(self, secret: bytes) -> None: + self._secret = secret + + def verify(self, headers: dict[str, str], body: bytes) -> bool: + expected = hmac.new(self._secret, body, hashlib.sha256).hexdigest() + return hmac.compare_digest(headers.get('x-signature', ''), expected) + + def parse(self, headers: dict[str, str], body: bytes) -> list[object]: + payload = json.loads(body) + return [PullRequestTouched(payload['repository'], payload['number'])] + + +async def test_webhook_endpoint_verifies_and_queues() -> None: + queue = EventQueue() + app = create_app(_SignedWebhooks(b'secret'), queue) # type: ignore[arg-type] + client = app.test_client() + body = json.dumps({'repository': REPOSITORY, 'number': 3}).encode() + + response = await client.post( + '/webhook', data = body, headers = {'X-Signature': 'bogus'} + ) + assert response.status_code == 401 + assert queue._queue.qsize() == 0 + + signature = hmac.new(b'secret', body, hashlib.sha256).hexdigest() + response = await client.post( + '/webhook', data = body, headers = {'X-Signature': signature} + ) + assert response.status_code == 202 + assert queue._queue.get_nowait() == PullRequestTouched(REPOSITORY, 3) + + response = await client.get('/healthz') + assert response.status_code == 200 + + +async def test_bench_sections_from_ci_artifact(tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + harness = Harness(store, forge, make_config(tmp_path / 'home'), clock) + await harness.start() + head = origin.push_pull_request(1, {'README': 'no test changes\n'}) + forge.set_pull_request(pull_request_info(1, head, origin.main_sha)) + + bench_zip = tmp_path / 'bench.zip' + with zipfile.ZipFile(bench_zip, 'w') as archive: + archive.writestr('ci-meta.json', json.dumps({'tested_sha': head})) + archive.writestr('c/x.bnch.cgout', 'summary: 990\n') + archive.writestr('cpp/x.bnch.cgout', 'summary: 1010\n') + forge.artifact_files['b1'] = bench_zip + forge.runs[head] = ci_run(head, conclusion = CIConclusion.SUCCESS, + artifacts = ( + ArtifactInfo('bench-results', 'b1', 1, 'sha256:bbb', False), + )) + await harness.step() + record = await store.find_pull_request('fake', REPOSITORY, 1) + assert record is not None + # The origin only has a release-c baseline, so only C is served. + [bench] = harness.publisher.reviews[record.review_id] + assert bench.name == 'Bench - C' + assert (bench.root / 'x.bnch.cgout').is_file() + assert Path(bench.details['baseline_dir'], 'x.bnch.cgout').is_file() + assert bench.root.is_relative_to(bench.home) + + # Nothing changed: the artifact isn't downloaded again. + downloads = forge.downloads + await store.mark_dirty(record.id) + await harness.step() + assert forge.downloads == downloads diff --git a/dev_tools/services/acknowledg-forge/typecheck.sh b/dev_tools/services/acknowledg-forge/typecheck.sh new file mode 100755 index 0000000000..424b1ab910 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/typecheck.sh @@ -0,0 +1,25 @@ +#!/usr/bin/bash + +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# Typecheck acknowledg-forge (the shared edgacknowledg.forge package and each +# forge service) with the same strictness typecheck-python.sh uses, in the +# services' own environment (it needs their third-party packages, which the +# rest of dev_tools doesn't). + +set -euo pipefail +cd "$(dirname "$0")" + +exec uv run --project github --with mypy --with-editable gitlab \ + mypy --no-namespace-packages \ + --scripts-are-modules \ + --strict-equality \ + --disallow-untyped-defs \ + --disallow-untyped-calls \ + --follow-imports=silent \ + "$(realpath ../../pylibs/edgacknowledg/forge)" \ + "$(realpath github/acknowledg_forge_github)" \ + "$(realpath gitlab/acknowledg_forge_gitlab)" diff --git a/dev_tools/services/acknowledg/README.md b/dev_tools/services/acknowledg/README.md index 160c21af5f..7fd288c962 100644 --- a/dev_tools/services/acknowledg/README.md +++ b/dev_tools/services/acknowledg/README.md @@ -177,7 +177,12 @@ The workflow reads these secrets from the `acknowledg` GitHub environment: | `ACKNOWLEDG_CLIENT_PATH` | Web root for the client files | | `ACKNOWLEDG_SERVER_PATH` | Server directory containing `venv/` | | `ACKNOWLEDG_SERVICE_NAME` | systemd unit running the server (e.g. `acknowledg-api`) | -| `ACKNOWLEDG_API_HOST` | API URL baked into the client (`VITE_EDG_ACKNOWLEDG_API_HOST`), including the scheme, e.g. `wss://acknowledg-api.example.com` | + +It also reads the public API URL from the `ACKNOWLEDG_API_HOST` **repository +variable** (not a secret: it's public, and it's shared with the +[acknowledg-forge](../acknowledg-forge/README.md) deploy). The URL is baked +into the client as `VITE_EDG_ACKNOWLEDG_API_HOST` and must include the scheme, +e.g. `wss://acknowledg-api.example.com`. The host must already be set up; the workflow does not manage the unit file: diff --git a/dev_tools/services/acknowledg/server/pyproject.toml b/dev_tools/services/acknowledg/server/pyproject.toml index ffe2e33785..1d361e959c 100644 --- a/dev_tools/services/acknowledg/server/pyproject.toml +++ b/dev_tools/services/acknowledg/server/pyproject.toml @@ -14,3 +14,5 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["acknowledg_server*", "edgacknowledg*"] +# acknowledg-forge (a separate service) isn't part of the server. +exclude = ["edgacknowledg.forge*"] From b79f5e1dd0e83c965824f7696f1cbe3eeaacf640 Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Thu, 15 Oct 2026 21:44:10 +0000 Subject: [PATCH 2/4] Move the CI changes for running benchmarks, installing valgrind, etc to another PR [] --- .github/workflows/acknowledg-forge-deploy.yml | 6 +- .github/workflows/build-and-test-ci.yml | 81 ------------------- .../pylibs/edgacknowledg/forge/config.py | 5 +- dev_tools/services/acknowledg-forge/README.md | 14 ++-- 4 files changed, 13 insertions(+), 93 deletions(-) diff --git a/.github/workflows/acknowledg-forge-deploy.yml b/.github/workflows/acknowledg-forge-deploy.yml index 0b5a061c76..794019a309 100644 --- a/.github/workflows/acknowledg-forge-deploy.yml +++ b/.github/workflows/acknowledg-forge-deploy.yml @@ -7,7 +7,7 @@ # # test the acknowledg-forge typecheck and tests (acknowledg-forge.yml) # publish build the image (build-edg-env acknowledg-forge) and push it to -# ghcr.io//acknowledg-forge:latest (deployments use the +# ghcr.io/edgcpp/acknowledg-forge:latest (deployments use the # pushed digest) # deploy over SSH, run the host's deploy helper (an administrator- # installed copy of @@ -79,14 +79,12 @@ jobs: - name: Push image id: push env: - OWNER: ${{ github.repository_owner }} REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - image="ghcr.io/${OWNER,,}/acknowledg-forge" + image=ghcr.io/edgcpp/acknowledg-forge printf '%s' "${REGISTRY_TOKEN}" \ | docker login ghcr.io --username "${GITHUB_ACTOR}" \ --password-stdin - docker tag docker.io/edgcpp/acknowledg-forge:latest "${image}:latest" docker push --quiet "${image}:latest" docker logout ghcr.io digest="$(docker inspect \ diff --git a/.github/workflows/build-and-test-ci.yml b/.github/workflows/build-and-test-ci.yml index 0ab3bc5e7b..f7ac35d94c 100644 --- a/.github/workflows/build-and-test-ci.yml +++ b/.github/workflows/build-and-test-ci.yml @@ -15,14 +15,9 @@ # build-gcc ─────────────┘ ├──► publish (on failure) # prepare ───────────────┬──► test-i686 ──┘ # build-i686 ────────────┘ -# bench (pull requests only; independent) # # A shared run timestamp keeps results under the same tests/runs// # tree; the publish job republishes failure artifacts from all test legs. -# The bench job runs the cachegrind benchmarks the way -# `edg-docker-bench --posture=release` does and uploads them as -# bench-results (c/ and cpp/ runs, compared by acknowledg-forge against the -# checked-in benchmarks/baselines/project/release-{c,cpp}). name: Build & Test CI @@ -309,79 +304,3 @@ jobs: path: aggregated-test-results/ if-no-files-found: warn retention-days: 14 - - bench: - name: Benchmark linux-gcc-release - if: ${{ github.event_name == 'pull_request' }} - # Benchmark infrastructure problems shouldn't fail a pull request's CI. - continue-on-error: true - runs-on: ubuntu-latest - timeout-minutes: 120 - container: - image: docker.io/edgcpp/ci-runner@sha256:73be12d128b8507f5feb9e73c25f48f14e6b041940ef165bce5fda8efe2b605c - defaults: - run: - shell: edg-container-exec "bash -eo pipefail {0}" - - steps: - - name: Check out repository - uses: actions/checkout@v7 - - # The ci-runner image doesn't ship valgrind yet (the dev-env image - # does); container job steps run as root, so install it here. - - name: Install valgrind - shell: bash - run: dnf install -y --setopt=install_weak_deps=False valgrind - - - name: Configure linux-gcc-release - env: - EDG_BASE: ${{ github.workspace }}/bases/docker/dev-env/gcc - run: cmake --preset linux-gcc-release - - - name: Build linux-gcc-release - env: - EDG_BASE: ${{ github.workspace }}/bases/docker/dev-env/gcc - run: cmake --build build/gcc-release --target cpfe cpfe-cp - - - name: Run benchmarks - env: - EDG_BASE: ${{ github.workspace }}/bases/docker/dev-env/gcc - EDG_BENCH_RUNS_KEPT: '0' - run: | - # Use the pull request's own benchmark tooling. - export PATH="${GITHUB_WORKSPACE}/dev_tools/bin:${PATH}" - export PYTHONPATH="${GITHUB_WORKSPACE}/dev_tools/pylibs" - mkdir -p bench-results - for run in c:bin/cpfe cpp:bin/cpfe-cp; do - name="${run%%:*}" - binary="${run#*:}" - (cd build/gcc-release && source environment.sh && - edg-bench "${binary}") - latest="$(ls -1d benchmarks/runs/*/ | sort | tail -n 1)" - mv "${latest}" "bench-results/${name}" - done - - - name: Record CI metadata - env: - TESTED_SHA: ${{ github.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - RUN_ID: ${{ github.run_id }} - RUN_ATTEMPT: ${{ github.run_attempt }} - run: | - python3 - > bench-results/ci-meta.json <<'EOF' - import json, os - print(json.dumps({ - 'tested_sha': os.environ['TESTED_SHA'], - 'head_sha': os.environ['HEAD_SHA'], - 'run_id': int(os.environ['RUN_ID']), - 'run_attempt': int(os.environ['RUN_ATTEMPT']), - }, indent = 2)) - EOF - - - name: Upload benchmark results - uses: actions/upload-artifact@v7 - with: - name: bench-results - path: bench-results/ - if-no-files-found: error - retention-days: 14 diff --git a/dev_tools/pylibs/edgacknowledg/forge/config.py b/dev_tools/pylibs/edgacknowledg/forge/config.py index 1d654c4f19..380a74c053 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/config.py +++ b/dev_tools/pylibs/edgacknowledg/forge/config.py @@ -50,8 +50,9 @@ class ForgeServiceConfig: home: Path database_url: str repositories: tuple[str, ...] - # Only pull requests targeting these branches are served, so tooling only - # ever runs from trusted, main-line commits. + # Only pull requests targeting these branches are served (e.g. not ones + # into feature branches). A pull request retargeted away from them has its + # review removed, as if it were closed. base_branches: frozenset[str] # Forge API base URL override (e.g. GitHub Enterprise, self-hosted GitLab). api_url: Optional[str] diff --git a/dev_tools/services/acknowledg-forge/README.md b/dev_tools/services/acknowledg-forge/README.md index dbad391cf3..e74362f14e 100644 --- a/dev_tools/services/acknowledg-forge/README.md +++ b/dev_tools/services/acknowledg-forge/README.md @@ -10,7 +10,7 @@ these sections, when they apply: | --- | --- | | Test - Changes | The pull request's expectation and recording diffs, recreated with `edg-rematerialize-test-run` on the merge-base | | Test - Failures | Unexpected differences from the latest CI run for the pull request's head (the `test-results-all` artifact) | -| Bench - C, Bench - C++ | The CI cachegrind results (the `bench-results` artifact), compared with the checked-in `benchmarks/baselines/project/release-{c,cpp}` | +| Bench - C, Bench - C++ | The CI cachegrind results (a `bench-results` artifact, once CI produces one), compared with the checked-in `benchmarks/baselines/project/release-{c,cpp}` | Optionally, a service account comments on the pull request whenever what's served changes in a meaningful way: once the pull request has *settled* (CI for @@ -133,8 +133,10 @@ from the environment variable shown as a fallback. `https:///webhook`, typically behind a reverse proxy to `ACKNOWLEDG_FORGE_WEBHOOK_BIND`. - **CI:** set `ACKNOWLEDG_FORGE_CI_WORKFLOW=build-and-test-ci.yml`. Its - `publish-test-results` and `bench` jobs upload the artifacts, including the - `ci-meta.json` that records the tested (merge) commit. + `publish-test-results` job uploads `test-results-all`, including the + `ci-meta.json` that records the tested (merge) commit. Benchmark results + need a CI job uploading `bench-results` (`c/` and `cpp/` runs plus + `ci-meta.json`), which is a separate change. ### GitLab @@ -164,7 +166,7 @@ public. It contains no credentials or deployment configuration, and the build job has no access to the deployment's secrets. To build locally, run `build-edg-env acknowledg-forge`; the result is tagged -`docker.io/edgcpp/acknowledg-forge:latest`. +`ghcr.io/edgcpp/acknowledg-forge:latest`. ### Host setup (once) @@ -467,8 +469,8 @@ add a service directory like `github/`, whose `__main__` passes a - On GitHub, pull request CI tests the merge commit. Failures and benchmarks are served against that commit when it can still be fetched, and against the pull request's head otherwise. -- The `ci-runner` image doesn't include valgrind yet, so the CI `bench` job - installs it at run time. +- Until CI uploads a `bench-results` artifact (a separate change), no bench + sections are served. - The tooling is frozen into the image, so a change to the test-home or expectations layout that `edg-rematerialize-test-run` must follow needs a new image. From b36ca8f7c8679c282af08775c69a7af5180b2ca0 Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Thu, 15 Oct 2026 21:52:54 +0000 Subject: [PATCH 3/4] Move the CI metadata to the other PR [] --- .github/workflows/build-and-test-ci.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/build-and-test-ci.yml b/.github/workflows/build-and-test-ci.yml index f7ac35d94c..21c0b0f1af 100644 --- a/.github/workflows/build-and-test-ci.yml +++ b/.github/workflows/build-and-test-ci.yml @@ -277,26 +277,6 @@ jobs: path: aggregated-test-results merge-multiple: true - # Record which commit was tested so consumers of the artifact (e.g. - # acknowledg-forge) can check out the matching tree. For pull requests - # this is the merge commit, not the PR head. - - name: Record CI metadata - env: - TESTED_SHA: ${{ github.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - RUN_ID: ${{ github.run_id }} - RUN_ATTEMPT: ${{ github.run_attempt }} - run: | - python3 - > aggregated-test-results/ci-meta.json <<'EOF' - import json, os - print(json.dumps({ - 'tested_sha': os.environ['TESTED_SHA'], - 'head_sha': os.environ['HEAD_SHA'], - 'run_id': int(os.environ['RUN_ID']), - 'run_attempt': int(os.environ['RUN_ATTEMPT']), - }, indent = 2)) - EOF - - name: Upload elist, elog, and .rt.tar.gz results uses: actions/upload-artifact@v7 with: From ea09d7fed56baf8d439ebbe392145c08947afca2 Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Fri, 16 Oct 2026 22:42:16 +0000 Subject: [PATCH 4/4] Security improvements [] --- dev_tools/bin/edg-acknowledg-cli | 18 +- dev_tools/bin/edg-bench-review | 9 +- dev_tools/bin/edgy-review | 13 +- dev_tools/pylibs/edgacknowledg/__init__.py | 5 +- .../pylibs/edgacknowledg/client/__init__.py | 47 +- .../edgacknowledg/client/bench/__init__.py | 137 ++-- .../pylibs/edgacknowledg/client/shared.py | 435 ++++++++++++ .../edgacknowledg/client/test/__init__.py | 255 ++++--- .../pylibs/edgacknowledg/forge/config.py | 25 + .../pylibs/edgacknowledg/forge/processor.py | 26 +- .../pylibs/edgacknowledg/forge/publisher.py | 458 ++++++++++++- .../edgacknowledg/forge/publisher_worker.py | 148 ++++ .../pylibs/edgacknowledg/forge/sandbox.py | 154 ++++- .../pylibs/edgacknowledg/forge/service.py | 18 +- .../pylibs/edgacknowledg/forge/workspace.py | 79 ++- dev_tools/services/acknowledg-forge/README.md | 47 +- .../tests/test_acknowledg_integration.py | 81 ++- .../tests/test_publisher_worker.py | 171 +++++ .../acknowledg-forge/tests/test_safety.py | 27 +- .../acknowledg-forge/tests/test_symlinks.py | 630 ++++++++++++++++++ dev_tools/services/acknowledg/PROTOCOL.md | 20 +- .../services/acknowledg/client/src/sockets.js | 2 +- .../server/acknowledg_server/__init__.py | 83 ++- 23 files changed, 2608 insertions(+), 280 deletions(-) create mode 100644 dev_tools/pylibs/edgacknowledg/forge/publisher_worker.py create mode 100644 dev_tools/services/acknowledg-forge/tests/test_publisher_worker.py create mode 100644 dev_tools/services/acknowledg-forge/tests/test_symlinks.py diff --git a/dev_tools/bin/edg-acknowledg-cli b/dev_tools/bin/edg-acknowledg-cli index 8e5fe1ac5f..1c3276f2c1 100755 --- a/dev_tools/bin/edg-acknowledg-cli +++ b/dev_tools/bin/edg-acknowledg-cli @@ -41,7 +41,7 @@ from edgacknowledg.client.bench import ( BenchSourceHandler, resolve_baseline_dir, ) -from edgacknowledg.client.shared import ReviewClientHandler +from edgacknowledg.client.shared import ReviewClientHandler, SandboxContext from edgacknowledg.client.test import TestEditHandler, TestSourceHandler from edgutil import eprint @@ -96,10 +96,12 @@ def _make_bench_session(parameters: Dict[str, Any], if session is not None: return session + # The run (which may lie elsewhere) and the bench home are all a review + # may read. + context = SandboxContext(bench_home, root) session = BenchReviewSession( - bench_home = bench_home, - run_dir = root, - baseline_dir = resolve_baseline_dir(bench_home, tag), + run_dir = context.subpath(root), + baseline_dir = resolve_baseline_dir(context.subpath(bench_home), tag), tag = tag, threshold_slow = float( parameters.get('slower_threshold', DEFAULT_THRESHOLD_SLOW) @@ -131,11 +133,15 @@ def _make_handler(section: Dict[str, Any], if section_type == 'test.src': if test_home is None: raise ValueError('test home required for test.src sections') + root = Path(parameters['root']) + # The run (which may lie elsewhere) and the test home are all a review + # may read. + context = SandboxContext(test_home, root) return TestSourceHandler( section_name, slug, - test_home, - Path(parameters['root']) + context.subpath(test_home), + context.subpath(root) ) if section_type == 'test.edit': return TestEditHandler(section_name, slug, worker_pool) diff --git a/dev_tools/bin/edg-bench-review b/dev_tools/bin/edg-bench-review index 8beb0fd3c3..7a49b3823c 100755 --- a/dev_tools/bin/edg-bench-review +++ b/dev_tools/bin/edg-bench-review @@ -32,6 +32,7 @@ from edgacknowledg.client.bench import ( BenchSourceHandler, resolve_baseline_dir, ) +from edgacknowledg.client.shared import SandboxContext from edgacknowledg import IncompatibleProtocolVersion from edgutil import eprint @@ -166,14 +167,16 @@ def main() -> None: eprint(f"Path does not exist: {run_dir}") sys.exit(1) - baseline_dir = resolve_baseline_dir(bench_home, args.tag) + # The run (given with --root, it may lie elsewhere) and the bench home + # are all a review may read. + context = SandboxContext(bench_home, run_dir) + baseline_dir = resolve_baseline_dir(context.subpath(bench_home), args.tag) if not baseline_dir.exists(): eprint(f"Baseline directory does not exist: {baseline_dir}") sys.exit(1) session = BenchReviewSession( - bench_home = bench_home, - run_dir = run_dir, + run_dir = context.subpath(run_dir), baseline_dir = baseline_dir, tag = args.tag, threshold_slow = args.threshold_slow, diff --git a/dev_tools/bin/edgy-review b/dev_tools/bin/edgy-review index 14311c62ee..f0583a9709 100755 --- a/dev_tools/bin/edgy-review +++ b/dev_tools/bin/edgy-review @@ -23,6 +23,7 @@ from edgacknowledg.client import ( add_endpoint_arguments, form_section_http_url, ) +from edgacknowledg.client.shared import SandboxContext from edgacknowledg.client.test import TestEditHandler, TestSourceHandler from edgacknowledg import IncompatibleProtocolVersion from edgutil import eprint @@ -163,9 +164,15 @@ def main() -> None: review = await session.create_review( run_id, review_name, created_at ) - await review.add_section( - TestSourceHandler(args.section_name, slug, test_home, run_dir) - ) + # The run (given with --root, it may lie elsewhere) and the test + # home are all a review may read. + context = SandboxContext(test_home, run_dir) + await review.add_section(TestSourceHandler( + args.section_name, + slug, + context.subpath(test_home), + context.subpath(run_dir) + )) await review.add_section( TestEditHandler(args.section_name, slug, worker_pool) ) diff --git a/dev_tools/pylibs/edgacknowledg/__init__.py b/dev_tools/pylibs/edgacknowledg/__init__.py index 7c8d4bcfb2..e0ef8fdd33 100644 --- a/dev_tools/pylibs/edgacknowledg/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/__init__.py @@ -26,6 +26,9 @@ class RequestTags: INVALID_REQUEST = 'invalid' OUTDATED_PROTOCOL = 'outdated-proto' ROLE_OCCUPIED = 'role-occupied' + # A reviewer asked a provider for a file it may not read: provider -> + # server (naming the reviewer), which closes that reviewer's connection. + ILLEGAL_ACCESS = 'illegal-access' META_SECTION_NAME = 'meta.section.name' META_REVIEW_NAME = 'meta.review.name' META_REVIEW_TIMESTAMP = 'meta.review.timestamp' @@ -117,7 +120,7 @@ def request_tags_with_prefix(prefix: str) -> FrozenSet[str]: ALLOWED_BENCH_EDIT_TAGS ) -PROTOCOL_REVISION = 18 +PROTOCOL_REVISION = 19 # 10MiB MAX_MESSAGE_SIZE = 10485760 diff --git a/dev_tools/pylibs/edgacknowledg/client/__init__.py b/dev_tools/pylibs/edgacknowledg/client/__init__.py index 39ca7622a0..d8be255a57 100644 --- a/dev_tools/pylibs/edgacknowledg/client/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/__init__.py @@ -47,6 +47,7 @@ from edgacknowledg.client.shared import ( BackgroundWorkerPool, Channel, + IllegalFileAccessError, ReviewClientHandler, announce_section, ) @@ -384,6 +385,19 @@ async def send(self, msg: ProtocolMessage) -> None: await self._websocket.send(encode_message(msg)) +async def _send_reply_best_effort(channel: Channel, + reply: ProtocolMessage) -> None: + '''Send ``reply`` to a failed request, ignoring any failure but a lost + connection (which the caller handles). + ''' + try: + await channel.send(reply) + except (ConnectionClosed, NotConnectedError): + raise + except Exception: + pass + + async def _reply_to_failed_request(channel: Channel, handler: ReviewClientHandler) -> None: '''Answer a request whose handler raised, and log why. @@ -395,12 +409,29 @@ async def _reply_to_failed_request(channel: Channel, f"Request to section {handler.subscription_type}/{handler.slug} failed:" ) traceback.print_exc() - try: - await channel.send(message(RequestTags.INVALID_REQUEST)) - except (ConnectionClosed, NotConnectedError): - raise - except Exception: - pass + await _send_reply_best_effort(channel, message(RequestTags.INVALID_REQUEST)) + + +async def _report_illegal_access(channel: Channel, + handler: ReviewClientHandler, + error: IllegalFileAccessError) -> None: + '''Report the reviewer whose request asked for a file outside the + section, so the server drops their connection. The section keeps serving + everyone else. + ''' + if error.reviewer_id is None: + # Not traced to a reviewer; just a failed request. + await _reply_to_failed_request(channel, handler) + else: + eprint( + f"Reviewer {error.reviewer_id} of section " + f"{handler.subscription_type}/{handler.slug} requested a file outside " + f"it; having the server disconnect them: {error}" + ) + await _send_reply_best_effort(channel, message( + RequestTags.ILLEGAL_ACCESS, + value = {'reviewer_id': error.reviewer_id} + )) async def _await_acknowledgement(future: asyncio.Future, @@ -479,6 +510,8 @@ async def _run_handler_loop(self, except IncompatibleProtocolVersion as ex: self._client.abort(ex) raise + except IllegalFileAccessError as ex: + await _report_illegal_access(channel, handler, ex) except Exception: await _reply_to_failed_request(channel, handler) finally: @@ -645,6 +678,8 @@ async def _run_handler_loop(self, except (RoleOccupied, ConnectionClosed, asyncio.CancelledError, IncompatibleProtocolVersion): raise + except IllegalFileAccessError as ex: + await _report_illegal_access(channel, handler, ex) except Exception: await _reply_to_failed_request(channel, handler) diff --git a/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py b/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py index 119b98849f..c0fd1ca952 100644 --- a/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py @@ -13,13 +13,11 @@ import sys import traceback -from pathlib import Path from typing import ( Any, Dict, List, Optional, - Sequence, ) from edgacknowledg import ( @@ -32,6 +30,10 @@ from edgacknowledg.client.shared import ( BackgroundWorkerPool, Channel, + ConfinedPath, + ConfinedPathComponent, + IllegalFileAccessError, + SandboxContext, trunc_display_content, ) import edgutil @@ -52,8 +54,8 @@ def _compute_percent_change(baseline_ops: int, run_ops: int) -> float: class BenchmarkMetadata: - def __init__(self, rel_benchmark_path: Path, baseline_ops: int, - run_ops: int) -> None: + def __init__(self, rel_benchmark_path: ConfinedPathComponent, + baseline_ops: int, run_ops: int) -> None: self.rel_benchmark_path = rel_benchmark_path self.baseline_ops = baseline_ops self.run_ops = run_ops @@ -72,11 +74,14 @@ def to_status_dict(self) -> Dict[str, Any]: } -def read_program_totals(file_path: Path) -> Optional[int]: +def read_program_totals(file_path: ConfinedPath) -> Optional[int]: + '''Return the Ir total of the ``.cgout`` file ``file_path``, or None if + it is missing or has none. + ''' if not file_path.exists(): return None - with open(file_path, 'rb') as file_handle: + with file_path.open('rb') as file_handle: try: # Advance to the last character of the file. file_handle.seek(-2, os.SEEK_END) @@ -99,14 +104,12 @@ def read_program_totals(file_path: Path) -> Optional[int]: return None -def collect_benchmark_metadata(rel_benchmark_path: Path, run_dir: Path, - baseline_dir: Path +def collect_benchmark_metadata(rel_benchmark_path: ConfinedPathComponent, + run_dir: ConfinedPath, + baseline_dir: ConfinedPath ) -> Optional[BenchmarkMetadata]: - baseline_file = baseline_dir / rel_benchmark_path - run_file = run_dir / rel_benchmark_path - - baseline_ops = read_program_totals(baseline_file) - run_ops = read_program_totals(run_file) + baseline_ops = read_program_totals(baseline_dir / rel_benchmark_path) + run_ops = read_program_totals(run_dir / rel_benchmark_path) if baseline_ops is None or run_ops is None: return None @@ -118,54 +121,68 @@ def collect_benchmark_metadata(rel_benchmark_path: Path, run_dir: Path, ) -def resolve_baseline_dir(bench_home: Path, tag: str) -> Path: +def resolve_baseline_dir(bench_home: ConfinedPath, tag: str) -> ConfinedPath: + '''The baselines of ``tag`` in ``bench_home``.''' if tag.startswith('@'): return bench_home / 'baselines' / 'user' / tag[1:] return bench_home / 'baselines' / 'project' / tag -def collect_all_benchmarks(run_dir: Path, - baseline_dir: Path) -> List[BenchmarkMetadata]: +def collect_all_benchmarks(run_dir: ConfinedPath, + baseline_dir: ConfinedPath + ) -> List[BenchmarkMetadata]: + '''Collect every benchmark in ``run_dir`` with a baseline in + ``baseline_dir``; benchmarks whose files may not be read are skipped + (with a warning). + ''' benchmark_metadatas: List[BenchmarkMetadata] = [] - for root, dirs, files in os.walk(run_dir): + for directory, dirs, files in run_dir.walk(): for file_name in files: - file_path = Path(root, file_name) + file_path = directory / file_name if file_path.suffix != '.cgout': continue rel_benchmark_path = file_path.relative_to(run_dir) - benchmark_metadata = collect_benchmark_metadata( - rel_benchmark_path, - run_dir, - baseline_dir - ) + try: + benchmark_metadata = collect_benchmark_metadata( + rel_benchmark_path, + run_dir, + baseline_dir + ) + except IllegalFileAccessError as ex: + eprint(f"Skipping benchmark {rel_benchmark_path}: {ex}") + continue if benchmark_metadata is None: continue benchmark_metadatas.append(benchmark_metadata) return benchmark_metadatas -def get_cg_annotate_output(baseline_path: Path, - run_path: Path, - *, - command_prefix: Sequence[str] = ()) -> str: +def get_cg_annotate_output(context: SandboxContext, + baseline_path: ConfinedPath, + run_path: ConfinedPath) -> str: '''Return the ``cg_annotate`` diff of ``run_path`` against - ``baseline_path``, with ``command_prefix`` (e.g. a sandbox launcher) - prepended to the command. + ``baseline_path``, run in ``context``. ''' - proc = subprocess.run( - [ - *command_prefix, - str(TOOL_PATH_CG_ANNOTATE), - '--no-annotate', - '--diff', - str(baseline_path), - str(run_path) - ], - stdout = subprocess.PIPE, - stderr = subprocess.STDOUT, - check = False - ) + try: + proc = subprocess.run( + context.command( + str(TOOL_PATH_CG_ANNOTATE), + '--no-annotate', + '--diff', + baseline_path, + run_path + ), + stdin = subprocess.DEVNULL, + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + timeout = context.command_timeout, + check = False + ) + except subprocess.TimeoutExpired: + return ( + f"cg_annotate timed out after {context.command_timeout:g} seconds\n" + ) return proc.stdout.decode(errors = 'replace') @@ -208,27 +225,31 @@ class BenchReviewSession: '''Shared source/editor state for one benchmark review section.''' def __init__(self, *, - bench_home: Path, - run_dir: Path, - baseline_dir: Path, + run_dir: ConfinedPath, + baseline_dir: ConfinedPath, tag: str, threshold_slow: float = DEFAULT_THRESHOLD_SLOW, - threshold_fast: float = DEFAULT_THRESHOLD_FAST, - command_prefix: Sequence[str] = ()) -> None: - self.bench_home = bench_home + threshold_fast: float = DEFAULT_THRESHOLD_FAST) -> None: + '''``run_dir`` and ``baseline_dir`` come from one ``SandboxContext``, + which every read, the baseline update, and cg_annotate go through. + ''' + if baseline_dir.context is not run_dir.context: + raise ValueError('the run and baselines belong to different contexts') + self.context = run_dir.context self.run_dir = run_dir self.baseline_dir = baseline_dir self.tag = tag self.threshold_slow = threshold_slow self.threshold_fast = threshold_fast - # Prepended to external commands run on the benchmark files. - self.command_prefix = tuple(command_prefix) self.statuses_payload: Dict[str, Any] = {} self.metadata_by_id: Dict[str, BenchmarkMetadata] = {} self.refresh_statuses() def refresh_statuses(self) -> None: - all_benchmarks = collect_all_benchmarks(self.run_dir, self.baseline_dir) + all_benchmarks = collect_all_benchmarks( + self.run_dir, + self.baseline_dir + ) self.statuses_payload = build_statuses_payload( all_benchmarks, self.threshold_slow, @@ -250,8 +271,8 @@ def update_baseline(self, bench_id: str) -> bool: if not run_file.exists(): return False - baseline_file.parent.mkdir(parents = True, exist_ok = True) - shutil.copy2(run_file, baseline_file) + baseline_file.path.parent.mkdir(parents = True, exist_ok = True) + shutil.copy2(run_file.path, baseline_file.path) print( f"Updated baseline for {bench_id} under tag {self.tag}:\n" f" {run_file} -> {baseline_file}" @@ -297,15 +318,17 @@ async def run_bench_source_handler(channel: Channel, )) return - baseline_path = session.baseline_dir / metadata.rel_benchmark_path + baseline_path = ( + session.baseline_dir / metadata.rel_benchmark_path + ) run_path = session.run_dir / metadata.rel_benchmark_path async def annotate_job() -> str: return await edgutil.async_to_thread( get_cg_annotate_output, + session.context, baseline_path, - run_path, - command_prefix = session.command_prefix + run_path ) annotate_text = await worker_pool.submit(annotate_job) @@ -325,6 +348,8 @@ async def annotate_job() -> str: value = { 'reviewer_id': reviewer_id } )) except Exception as ex: + if isinstance(ex, IllegalFileAccessError): + ex.reviewer_id = reviewer_id if sys.version_info >= (3, 11): ex.add_note(f"request triggered by reviewer: \"{reviewer_id}\"") raise ex diff --git a/dev_tools/pylibs/edgacknowledg/client/shared.py b/dev_tools/pylibs/edgacknowledg/client/shared.py index 0e88e855bb..0eb31ecdde 100644 --- a/dev_tools/pylibs/edgacknowledg/client/shared.py +++ b/dev_tools/pylibs/edgacknowledg/client/shared.py @@ -6,15 +6,22 @@ '''Shared AcknowlEDG client types used by test and bench providers.''' import asyncio +import errno import os +import stat +from pathlib import Path from typing import ( Any, Awaitable, Callable, Dict, List, + IO, + Iterator, Optional, + Sequence, + Tuple, Union, ) @@ -164,3 +171,431 @@ def trunc_display_content(content: str) -> Dict[str, Union[str, bool]]: 'content': content } return { 'content': content } + + + +class IllegalFileAccessError(Exception): + '''Raised when a request asks for a file outside the allowed tree. + + Providers record the requesting reviewer in ``reviewer_id``, so the + request loop can report them (``RequestTags.ILLEGAL_ACCESS``) and the + server can drop their connection. + ''' + + def __init__(self, *args: Any) -> None: + super().__init__(*args) + self.reviewer_id: Optional[str] = None + + +# Seconds an external command run for a reviewer may take, by default. +DEFAULT_COMMAND_TIMEOUT_S = 120.0 + + +class SandboxContext: + '''A security context: the trees a provider may read, whether links in + them may be followed, and how to run external commands on them. + Providers take ``ConfinedPath``s from one (``subpath``), which carry it + along, and reach every file (and run every command) through them. + + - ``roots``: the trees that may be read, e.g. a test home and a run + directory given with --root that lies elsewhere. None is special; a + root inside another (lexically) is folded into it, and every path is + confined to the one root holding it. + - ``allow_symlinks``: a trusted tree (a local review) may use links, as + long as they stay inside their root. A tree with someone else's content + (e.g. a pull request served by acknowledg-forge) may not: no link is + followed. + - ``command_prefix``: prepended to every command run on the trees (e.g. a + sandbox launcher that only shows the command the trees, read-only; it + must cover every root). + - ``command_timeout``: seconds such a command may run. + ''' + + def __init__(self, + *roots: Path, + allow_symlinks: bool = True, + command_prefix: Sequence[str] = (), + command_timeout: float = DEFAULT_COMMAND_TIMEOUT_S) -> None: + if len(roots) == 0: + raise ValueError('a sandbox context needs at least one root') + reduced: List[Path] = [] + for root in roots: + if not any(_is_under(root, other) for other in reduced): + reduced = [other for other in reduced if not _is_under(other, root)] + reduced.append(root) + self._roots = tuple(reduced) + self._allow_symlinks = allow_symlinks + self._command_prefix = tuple(command_prefix) + self._command_timeout = command_timeout + + @property + def roots(self) -> Tuple[Path, ...]: + '''The (reduced) roots, as plain paths (where the checks end).''' + return self._roots + + @property + def allow_symlinks(self) -> bool: + return self._allow_symlinks + + @property + def command_timeout(self) -> float: + return self._command_timeout + + def subpath(self, path: Path) -> 'ConfinedPath': + '''``path`` (a plain path claimed to be in one of the roots), + confined to that root. + ''' + for root in self._roots: + if _is_under(path, root): + return ConfinedPath(self, root)._derive(path) + raise IllegalFileAccessError( + f"A request was made attempting to retrieve the file: \"{path}\"!" + ) + + def command(self, *args: Union[str, 'ConfinedPath']) -> List[str]: + '''The argument vector running ``args`` in this context. Files are + passed as ``ConfinedPath`` of this context (so they were validated, and + lie in its roots), never as plain paths. + ''' + command = list(self._command_prefix) + for arg in args: + if isinstance(arg, ConfinedPath): + if arg.context is not self: + raise ValueError(f"{arg!r} belongs to another context") + command.append(str(arg.path)) + else: + command.append(arg) + return command + + +def _is_under(path: Path, root: Path) -> bool: + '''Whether ``path`` is ``root`` or lies under it, lexically.''' + try: + path.relative_to(root) + except ValueError: + return False + return True + + +class ConfinedPathComponent: + '''A relative path between two ``ConfinedPath``s (``relative_to``), to be + joined onto ``ConfinedPath``s (``/``, ``joinpath``) and nothing else: it + is neither a ``PurePath`` nor ``os.PathLike``, so no other path API + takes it. Its string form (``str``, ``as_posix``) is for display and + identifiers. + + It never holds '..', '.', or an absolute part, but joining it still + validates the result like any other ``ConfinedPath``. + ''' + + def __init__(self, parts: Sequence[str]) -> None: + for part in parts: + if part in ('', '.', '..') or '/' in part or '\0' in part: + raise ValueError(f"invalid path component: {part!r}") + self._parts = tuple(parts) + + @property + def parts(self) -> Tuple[str, ...]: + return self._parts + + @property + def name(self) -> str: + return self._parts[-1] if len(self._parts) != 0 else '' + + def as_posix(self) -> str: + return '/'.join(self._parts) if len(self._parts) != 0 else '.' + + def __str__(self) -> str: + return self.as_posix() + + def __repr__(self) -> str: + return f"ConfinedPathComponent({self.as_posix()!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ConfinedPathComponent): + return NotImplemented + return self._parts == other._parts + + def __lt__(self, other: 'ConfinedPathComponent') -> bool: + return self._parts < other._parts + + def __hash__(self) -> int: + return hash(self._parts) + + +class ConfinedPath: + '''A path confined to a tree: it behaves much like a ``pathlib.Path`` + (``/``, ``joinpath``, ``parent``, ``name``, ``exists``, ``open``, ...), + but every ``ConfinedPath`` is validated when it is made, so one can't + point outside its tree; ``IllegalFileAccessError`` is raised instead. + Providers use it for every path a request influences. + + A path is valid when it is lexically under the tree's root (no '..') and: + + - if the context allows symlinks, it is still under the root once + canonicalized (links resolved), so links may only stay inside; + - otherwise, no component below the root is a symlink or anything but a + directory or regular file. Its ``open``, ``exists`` and the like never + follow a link either, even one that appeared after the path was + validated. + + A missing path is valid; reading it fails as usual. The root itself is + trusted: whoever makes the tree vouches for it. + + Its tree (one of a ``SandboxContext``'s roots) and policy come from that + context; ``SandboxContext.subpath`` makes them. + + Unlike ``pathlib.Path`` it is not ``os.PathLike``, so it can't be handed + to ``open`` or a library by accident: such uses take ``.path`` + explicitly (where the checks above end). + ''' + + def __init__(self, context: SandboxContext, root: Path) -> None: + '''``root`` (one of ``context``'s roots); see ``SandboxContext.subpath`` + for making one. + ''' + if root not in context.roots: + raise ValueError(f"{root} is not a root of the context") + self._context = context + self._root = root + self._path = root + self._untrusted = not context.allow_symlinks + + def _derive(self, path: Path) -> 'ConfinedPath': + derived = ConfinedPath(self._context, self._root) + derived._path = path + derived._validate() + return derived + + def _illegal(self, path: Optional[Path] = None) -> IllegalFileAccessError: + return IllegalFileAccessError( + f"A request was made attempting to retrieve the file: " + f"\"{path if path is not None else self._path}\"!" + ) + + def _relative_parts(self, path: Path) -> List[str]: + try: + parts = list(path.relative_to(self._root).parts) + except ValueError: + raise self._illegal(path) from None + if any(part in ('', '.', '..') for part in parts): + raise self._illegal(path) + return parts + + def _validate(self) -> None: + parts = self._relative_parts(self._path) + if not self._untrusted: + resolved_root = self._root.resolve() + resolved_path = self._path.resolve() + if (Path(os.path.commonpath([resolved_root, resolved_path])) != + resolved_root): + raise self._illegal() + return + current = self._root + for part in parts: + current = current / part + try: + mode = os.lstat(current).st_mode + except FileNotFoundError: + return + if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)): + raise self._illegal() + + # Deriving paths (each validated) + + def __truediv__(self, + other: Union[str, ConfinedPathComponent]) -> 'ConfinedPath': + return self.joinpath(other) + + def joinpath(self, + *others: Union[str, ConfinedPathComponent]) -> 'ConfinedPath': + '''Join ``others``: strings (from requests, say) or components taken + from other ``ConfinedPath``s. + ''' + parts: List[str] = [] + for other in others: + if isinstance(other, ConfinedPathComponent): + parts += other.parts + elif isinstance(other, str): + parts.append(other) + else: + raise TypeError(f"can't join {type(other).__name__} to a " + f"ConfinedPath") + return self._derive(self._path.joinpath(*parts)) + + def relative_to(self, other: 'ConfinedPath') -> ConfinedPathComponent: + '''This path relative to ``other`` (of the same context), which it + must lie under. + ''' + if other._context is not self._context: + raise ValueError(f"{other!r} belongs to another context") + try: + relative = self._path.relative_to(other._path) + except ValueError: + raise ValueError(f"{self!r} is not under {other!r}") from None + return ConfinedPathComponent(relative.parts) + + def subpath(self, path: Path) -> 'ConfinedPath': + '''``path`` (a plain path claimed to be in this path's tree), + confined. + ''' + return self._derive(path) + + @property + def parent(self) -> 'ConfinedPath': + if self._path == self._root: + raise self._illegal(self._path.parent) + return self._derive(self._path.parent) + + def with_name(self, name: str) -> 'ConfinedPath': + return self._derive(self._path.with_name(name)) + + def with_suffix(self, suffix: str) -> 'ConfinedPath': + return self._derive(self._path.with_suffix(suffix)) + + # Properties + + @property + def path(self) -> Path: + '''The plain path, for commands and libraries; using it bypasses + the checks reading through this object makes. + ''' + return self._path + + @property + def context(self) -> SandboxContext: + return self._context + + @property + def tree_root(self) -> 'ConfinedPath': + '''The root of this path's tree (the context's root holding it).''' + return ConfinedPath(self._context, self._root) + + @property + def relative(self) -> ConfinedPathComponent: + '''This path relative to its tree's root.''' + return self.relative_to(self.tree_root) + + @property + def name(self) -> str: + return self._path.name + + @property + def suffix(self) -> str: + return self._path.suffix + + @property + def suffixes(self) -> List[str]: + return self._path.suffixes + + @property + def stem(self) -> str: + return self._path.stem + + def __str__(self) -> str: + return str(self._path) + + def __repr__(self) -> str: + links = 'no links' if self._untrusted else 'links inside' + return (f"ConfinedPath({str(self._path)!r} in {str(self._root)!r}, " + f"{links})") + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ConfinedPath): + return NotImplemented + return (self._context is other._context and + (self._root, self._path) == (other._root, other._path)) + + def __hash__(self) -> int: + return hash((id(self._context), self._root, self._path)) + + # File system queries (never following a link in an untrusted tree) + + def _mode(self) -> Optional[int]: + try: + if self._untrusted: + return os.lstat(self._path).st_mode + return os.stat(self._path).st_mode + except (FileNotFoundError, NotADirectoryError): + return None + + def exists(self) -> bool: + return self._mode() is not None + + def is_file(self) -> bool: + mode = self._mode() + return mode is not None and stat.S_ISREG(mode) + + def is_dir(self) -> bool: + mode = self._mode() + return mode is not None and stat.S_ISDIR(mode) + + def walk(self) -> Iterator[Tuple['ConfinedPath', List[str], List[str]]]: + '''Like ``os.walk`` (top-down, not following links), yielding each + directory as a ``ConfinedPath``. Directory names may be pruned in place. + In an untrusted tree, entries that are neither directories nor regular + files are left out. + ''' + def has_type(directory: str, name: str, is_type: Any) -> bool: + try: + return bool(is_type(os.lstat(os.path.join(directory, name)).st_mode)) + except FileNotFoundError: + return False + + self._validate() + for directory, dirs, files in os.walk(self._path): + if self._untrusted: + dirs[:] = [d for d in dirs if has_type(directory, d, stat.S_ISDIR)] + files[:] = [f for f in files + if has_type(directory, f, stat.S_ISREG)] + yield self._derive(Path(directory)), dirs, files + + # Reading + + def open(self, mode: str = 'r', **kwargs: Any) -> IO[Any]: + '''Open for reading (``mode`` 'r' or 'rb', other arguments as for + ``open``). + ''' + if mode not in ('r', 'rb'): + raise ValueError(f"unsupported mode: {mode!r}") + self._validate() + if not self._untrusted: + return open(self._path, mode, **kwargs) + return self._open_without_links(mode, **kwargs) + + def read_text(self, **kwargs: Any) -> str: + with self.open('r', **kwargs) as handle: + return str(handle.read()) + + def read_bytes(self) -> bytes: + with self.open('rb') as handle: + return bytes(handle.read()) + + def _open_without_links(self, mode: str, **kwargs: Any) -> IO[Any]: + parts = self._relative_parts(self._path) + if len(parts) == 0: + raise self._illegal() + flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0) + dir_fd = os.open(str(self._root), flags | os.O_DIRECTORY) + try: + for part in parts[:-1]: + # O_NOFOLLOW makes a link fail with ELOOP (or ENOTDIR). + next_fd = os.open(part, flags | os.O_DIRECTORY, dir_fd = dir_fd) + os.close(dir_fd) + dir_fd = next_fd + # Non-blocking, so a FIFO can't stall the open. + fd = os.open(parts[-1], flags | os.O_NONBLOCK, dir_fd = dir_fd) + except OSError as ex: + if ex.errno in (errno.ELOOP, errno.ENOTDIR): + raise self._illegal() from ex + raise + finally: + os.close(dir_fd) + try: + if not stat.S_ISREG(os.fstat(fd).st_mode): + raise self._illegal() + os.set_blocking(fd, True) + return open(fd, mode, **kwargs) + except BaseException: + os.close(fd) + raise diff --git a/dev_tools/pylibs/edgacknowledg/client/test/__init__.py b/dev_tools/pylibs/edgacknowledg/client/test/__init__.py index 8792e79793..df8143127a 100644 --- a/dev_tools/pylibs/edgacknowledg/client/test/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/test/__init__.py @@ -16,11 +16,11 @@ from pathlib import Path, PurePosixPath from typing import ( + IO, Any, Dict, List, Optional, - Sequence, Tuple, ) @@ -34,6 +34,8 @@ from edgacknowledg.client.shared import ( BackgroundWorkerPool, Channel, + ConfinedPath, + IllegalFileAccessError, trunc_display_content, ) from edgutil import eprint @@ -43,47 +45,17 @@ _StatusDict = Dict[str, Dict[str, Any]] -class IllegalFileAccessError(Exception): - '''Raised when a request asks for a file outside the allowed tree.''' - pass +# Re-exported: it used to be defined here. +__all__ = ['IllegalFileAccessError'] -def _validate_file_access(test_home: Path, file_path: Path) -> None: - '''Validate that ``file_path`` is under ``test_home``.''' - test_home = test_home.resolve() - file_path = file_path.resolve() - - okay = False - try: - if Path(os.path.commonpath([test_home, file_path])) == test_home: - okay = True - finally: - if not okay: - raise IllegalFileAccessError( - f"A request was made attempting to retrieve the file: \"{file_path}\"!" - ) - - -def _safe_compose_path(test_home: Path, *path_components: str) -> Path: - '''Validated Path.joinpath for network-influenced path components.''' - result_path = test_home.joinpath(*path_components) - _validate_file_access(test_home, result_path) - return result_path - - -def _rt_path_for(test_home: Path, root_dir: Path, test_id: str) -> Path: - rt_rel = PurePosixPath(test_id).with_suffix( +def _rt_path_for(config_dir: ConfinedPath, test_id: str) -> ConfinedPath: + return config_dir / str(PurePosixPath(test_id).with_suffix( edgtest.runtest.RUN_TEST_OUTPUT_SUFFIX - ) - return _safe_compose_path( - test_home, - *root_dir.relative_to(test_home).parts, - *rt_rel.parts - ) + )) -def _create_status_dict(test_home: Path, - elog_file_paths: List[Path], *, +def _create_status_dict(elog_file_paths: List[ConfinedPath], *, is_multi_config_run: bool) -> _StatusDict: # test_id -> config_name -> combined_diff_hash. pending: Dict[str, Dict[str, str]] = {} @@ -92,7 +64,7 @@ def _create_status_dict(test_home: Path, for elog_file_path in elog_file_paths: config_name = elog_file_path.parent.name - with open(elog_file_path, 'r') as file_handle: + with elog_file_path.open() as file_handle: for line in file_handle: key, parsed_diff = ( edgtest.runtest.parse_test_status_diff(line) @@ -105,13 +77,9 @@ def _create_status_dict(test_home: Path, remarks[key] = parsed_diff.remark if config_name not in pending[key]: - rt_file_path = _rt_path_for( - test_home, - elog_file_path.parent, - key - ) + rt_file_path = _rt_path_for(elog_file_path.parent, key) pending[key][config_name] = ( - edgtest.runtest.grouping_combined_diff_hash(rt_file_path) + edgtest.runtest.grouping_combined_diff_hash(rt_file_path.path) ) lines_by_test_config[key][config_name] = [] @@ -139,18 +107,22 @@ def _create_status_dict(test_home: Path, return keyed_statuses -def _matching_change_files(changes_files: Dict[str, Path], - new_name: str) -> List[Path]: +def _matching_change_files(changes_files: Dict[str, ConfinedPath], + new_name: str) -> List[ConfinedPath]: regress_files = [f.with_name(new_name) for f in changes_files.values()] regress_files[:] = [f for f in regress_files if f.exists()] return regress_files -def _test_if_any_output_exists(recordings_dir: Path, +def _test_if_any_output_exists(recordings_dir: ConfinedPath, suite_env: Dict[str, str], case_number: int, - command_number: int) -> Optional[Path]: - '''Return a recording path if one exists for the case/command, else None.''' + command_number: int) -> Optional[ConfinedPath]: + '''Return a recording path if one exists for the case/command, else None. + + Recordings are read and shown to reviewers, so they're confined like any + other requested file. + ''' names_to_test = edgtest.runtest.get_recording_possible_names( suite_env, case_number, @@ -164,9 +136,20 @@ def _test_if_any_output_exists(recordings_dir: Path, return None -def _test_if_any_matches_exists(recordings_dir: Path, +def _copy_recording(recording: Optional[ConfinedPath], + destination: Path) -> Optional[Path]: + '''Copy ``recording`` (if any) to the private ``destination``, reading it + through its checks, for tools that take a plain path. + ''' + if recording is None: + return None + destination.write_bytes(recording.read_bytes()) + return destination + + +def _test_if_any_matches_exists(recordings_dir: ConfinedPath, suite_env: Dict[str, str], - case_number: int) -> Optional[Path]: + case_number: int) -> Optional[ConfinedPath]: '''Return a matches recording path if one exists for the case, else None.''' names_to_test = edgtest.runtest.get_matches_possible_names( suite_env, @@ -180,40 +163,41 @@ def _test_if_any_matches_exists(recordings_dir: Path, return None +def _assoc_files_path(test_home: ConfinedPath, + test_path: ConfinedPath) -> Optional[ConfinedPath]: + assoc_files_path = edgtest.runtest.get_assoc_files_path(test_path.path) + if assoc_files_path is None: + return None + return test_home.subpath(assoc_files_path) + + async def run_test_source_handler(channel: Channel, - test_home: Path, - relative_run_dir: Path, - changes_files: Dict[str, Path], - *, - command_prefix: Sequence[str] = ()) -> None: - '''Handle one test source request on ``channel``. - - ``command_prefix`` is prepended to external commands run on files from the - test tree (e.g. a sandbox launcher when the tree is untrusted). + test_home: ConfinedPath, + run_dir: ConfinedPath, + changes_files: Dict[str, ConfinedPath] + ) -> None: + '''Handle one test source request on ``channel``. Every file is reached + from ``test_home`` (the root of its tree), so it can't leave it, and + commands run on them (ifc-dump) in its context. ''' request_tag, request_value, _section = decode_response(await channel.recv()) is_multi_config_run = len(changes_files) > 1 - def decompose_test_id(test_id: str) -> Tuple[Path, str]: + def decompose_test_id(test_id: str) -> Tuple[ConfinedPath, str]: test_id_match = MULTI_CONFIG_TEST_FORMAT.match(test_id) assert test_id_match is not None return changes_files[test_id_match.group(1)], test_id_match.group(2) - def test_id_to_path(test_home: Path, test_id: str) -> Path: + def test_id_to_path(test_id: str) -> ConfinedPath: test_id_parts = test_id.split('/') # See if there's a patched version of this test that should be displayed # instead. - patched_test_version = _safe_compose_path( - test_home, - *(relative_run_dir.parts), - 'patched-tests', - *test_id_parts - ) + patched_test_version = run_dir.joinpath('patched-tests', *test_id_parts) if patched_test_version.exists(): return patched_test_version - return _safe_compose_path(test_home, 'tests', *test_id_parts) + return test_home.joinpath('tests', *test_id_parts) if request_value is not None and 'reviewer_id' in request_value: reviewer_id = request_value['reviewer_id'] @@ -225,7 +209,6 @@ def test_id_to_path(test_home: Path, test_id: str) -> Path: { 'name': 'Changes List', 'statuses': _create_status_dict( - test_home, _matching_change_files(changes_files, 'changes.elog'), is_multi_config_run = is_multi_config_run ) @@ -241,7 +224,6 @@ def test_id_to_path(test_home: Path, test_id: str) -> Path: { 'name': 'Regressions List', 'statuses': _create_status_dict( - test_home, regress_files, is_multi_config_run = is_multi_config_run ) @@ -257,7 +239,6 @@ def test_id_to_path(test_home: Path, test_id: str) -> Path: { 'name': 'Improvements List', 'statuses': _create_status_dict( - test_home, improve_files, is_multi_config_run = is_multi_config_run ) @@ -279,11 +260,7 @@ def test_id_to_path(test_home: Path, test_id: str) -> Path: request_tag == RequestTags.TEST_SHOW_RAW_OUTPUT): configured_test_id = request_value['id'] changes_file_path, test_id = decompose_test_id(configured_test_id) - rt_file_path = _rt_path_for( - test_home, - changes_file_path.parent, - test_id - ) + rt_file_path = _rt_path_for(changes_file_path.parent, test_id) if not rt_file_path.exists(): await channel.send(message( @@ -299,7 +276,7 @@ def test_id_to_path(test_home: Path, test_id: str) -> Path: return async def form_curr_diff_text(config_name: str, - case_rt_path: Path) -> str: + case_rt_path: ConfinedPath) -> str: '''Live-diff observed vs recordings per command. Per-command body content is diffed (recording command line stripped), @@ -307,25 +284,26 @@ async def form_curr_diff_text(config_name: str, presentation as static. ''' suite_name = test_id.split('/')[0] - suite_path = _safe_compose_path(test_home, 'tests', suite_name) + suite_path = test_home / 'tests' / suite_name # Load the suite ENV and then populate EDG_CONFIG with the name of # the config that execute so as to restore the correct active config, # ensuring that the diff can be run against not only the default # recordings but the prior recordings of that same active config. - suite_env = edgtest.load_test_suite_env(suite_path) + suite_env = edgtest.load_test_suite_env(suite_path.path) suite_env['EDG_CONFIG'] = config_name # Use the test path to gather the recording directory to find the # status-quo recording files to be examined. - test_path = test_id_to_path(test_home, test_id) - recordings_dir = edgtest.runtest.get_assoc_recordings_path( - test_path + test_path = test_id_to_path(test_id) + recordings_dir = test_home.subpath( + edgtest.runtest.get_assoc_recordings_path(test_path.path) ) with tempfile.TemporaryDirectory() as tmp_dir_name: tmp_dir = Path(tmp_dir_name) entries: List[Tuple[Dict[str, Any], List[str]]] = [] - with edgtest.runtest.RunTestOutput(case_rt_path) as rt_output: + with edgtest.runtest.RunTestOutput( + case_rt_path.path) as rt_output: for case_number, case in enumerate( rt_output.metadata.get('cases', []), start = 1 @@ -348,11 +326,14 @@ async def form_curr_diff_text(config_name: str, except FileNotFoundError: observed_path.touch() - expected_path = _test_if_any_output_exists( - recordings_dir, - suite_env, - case_number, - command_number + expected_path = _copy_recording( + _test_if_any_output_exists( + recordings_dir, + suite_env, + case_number, + command_number + ), + cmd_dir / 'expected' ) diff_hash = await edgtest.runtest.write_content_diff( @@ -387,10 +368,13 @@ async def form_curr_diff_text(config_name: str, except FileNotFoundError: continue - expected_matches = _test_if_any_matches_exists( - recordings_dir, - suite_env, - case_number + expected_matches = _copy_recording( + _test_if_any_matches_exists( + recordings_dir, + suite_env, + case_number + ), + matches_dir / 'expected' ) matches_diff_hash = await edgtest.runtest.write_content_diff( expected_matches, @@ -418,14 +402,14 @@ async def form_curr_diff_text(config_name: str, ) if request_tag == RequestTags.TEST_SHOW_DIFF: - with edgtest.runtest.RunTestOutput(rt_file_path) as rt_output: + with edgtest.runtest.RunTestOutput(rt_file_path.path) as rt_output: static_text = ''.join(rt_output.combined_static_diff()) client_response = { 'id': configured_test_id, **trunc_display_content(static_text) } elif request_tag == RequestTags.TEST_SHOW_RAW_OUTPUT: - with edgtest.runtest.RunTestOutput(rt_file_path) as rt_output: + with edgtest.runtest.RunTestOutput(rt_file_path.path) as rt_output: raw_output = ''.join(rt_output.combined_raw_output()) client_response = { 'id': configured_test_id, @@ -441,11 +425,7 @@ async def form_curr_diff_text(config_name: str, if config_name == rep_config: continue sibling_changes = changes_files[config_name] - sibling_rt = _rt_path_for( - test_home, - sibling_changes.parent, - test_id - ) + sibling_rt = _rt_path_for(sibling_changes.parent, test_id) if not sibling_rt.exists(): divergent_configs.append(config_name) continue @@ -475,13 +455,14 @@ async def form_curr_diff_text(config_name: str, )) elif request_tag == RequestTags.TEST_LIST_ASSOC_FILES: test_id = request_value['id'] - test_path = test_id_to_path(test_home, test_id) - assoc_files_path = edgtest.runtest.get_assoc_files_path(test_path) + test_path = test_id_to_path(test_id) + assoc_files_path = _assoc_files_path(test_home, test_path) file_list = [] if assoc_files_path is not None and assoc_files_path.exists(): - for root, dirs, filenames in os.walk(assoc_files_path): + for directory, dirs, filenames in assoc_files_path.walk(): + root = str(directory.path) dirs[:] = [ d for d in dirs if edgtest.runtest.is_valid_assoc_path(root, d) ] @@ -489,13 +470,12 @@ async def form_curr_diff_text(config_name: str, if not edgtest.runtest.is_valid_assoc_path(root, filename): continue - file_path = Path(root, filename) + file_path = directory / filename - # Get the relative path as path components and then form a string - # using the / separator (to canonicalize the path regardless of - # operating system). + # The relative path with / separators (to canonicalize the path + # regardless of operating system). relative_path = file_path.relative_to(assoc_files_path) - file_list.append('/'.join(relative_path.parts)) + file_list.append(relative_path.as_posix()) file_list.sort() @@ -511,20 +491,15 @@ async def form_curr_diff_text(config_name: str, )) elif request_tag == RequestTags.TEST_SHOW_SOURCE: test_id = request_value['id'] - test_path = test_id_to_path(test_home, test_id) + test_path = test_id_to_path(test_id) - path_to_show: Optional[Path] = None + path_to_show: Optional[ConfinedPath] = None if ('assoc_file' in request_value and request_value['assoc_file'] is not None): assoc_file_path_parts = request_value['assoc_file'].split('/') - assoc_files_path = edgtest.runtest.get_assoc_files_path(test_path) + assoc_files_path = _assoc_files_path(test_home, test_path) if assoc_files_path is not None: - relative_assoc_files_path = assoc_files_path.relative_to(test_home) - path_to_show = _safe_compose_path( - test_home, - *relative_assoc_files_path.parts, - *assoc_file_path_parts - ) + path_to_show = assoc_files_path.joinpath(*assoc_file_path_parts) else: path_to_show = test_path @@ -541,16 +516,29 @@ async def form_curr_diff_text(config_name: str, } )) elif path_to_show.suffix == '.ifc': - cmd_args = [*command_prefix, 'ifc-dump', str(path_to_show)] + context = test_home.context + cmd_args = context.command('ifc-dump', path_to_show) # Execute the ifc-dump command. proc = await asyncio.create_subprocess_exec( *cmd_args, + stdin = asyncio.subprocess.DEVNULL, stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.STDOUT ) - stdout, _ = await proc.communicate() - ifc_dump_output = stdout.decode() + try: + stdout, _ = await asyncio.wait_for( + proc.communicate(), + context.command_timeout + ) + ifc_dump_output = stdout.decode(errors = 'replace') + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + ifc_dump_output = ( + f"ifc-dump timed out after {context.command_timeout:g} " + f"seconds\n" + ) await channel.send(message( RequestTags.TEST_SHOW_SOURCE, @@ -564,7 +552,7 @@ async def form_curr_diff_text(config_name: str, } )) else: - with open(path_to_show, 'r', errors = 'replace') as file_handle: + with path_to_show.open(errors = 'replace') as file_handle: await channel.send(message( RequestTags.TEST_SHOW_SOURCE, value = { @@ -584,6 +572,8 @@ async def form_curr_diff_text(config_name: str, } )) except Exception as ex: + if isinstance(ex, IllegalFileAccessError): + ex.reviewer_id = reviewer_id if sys.version_info >= (3, 11): ex.add_note(f"request triggered by reviewer: \"{reviewer_id}\"") raise ex @@ -595,17 +585,21 @@ class TestSourceHandler: def __init__(self, section_name: str, slug: str, - test_home: Path, - root: Path, - *, - command_prefix: Sequence[str] = ()) -> None: + test_home: ConfinedPath, + root: ConfinedPath) -> None: + '''``test_home`` and the run directory ``root`` come from one + ``SandboxContext``, which requests reach files only through. + ''' + if root.context is not test_home.context: + raise ValueError('the test home and run belong to different contexts') self._section_name = section_name self._slug = slug self._test_home = test_home self._root = root - self._command_prefix = tuple(command_prefix) - self._changes_files = edgtest.find_changes_files(root) - self._relative_run_dir = root.relative_to(test_home) + self._changes_files = { + config: self._root.subpath(path) + for config, path in edgtest.find_changes_files(self._root.path).items() + } @property def subscription_type(self) -> str: @@ -624,9 +618,8 @@ async def handle_request(self, channel: Channel) -> None: await run_test_source_handler( channel, self._test_home, - self._relative_run_dir, - self._changes_files, - command_prefix = self._command_prefix + self._root, + self._changes_files ) async def handle_test_edit_request( diff --git a/dev_tools/pylibs/edgacknowledg/forge/config.py b/dev_tools/pylibs/edgacknowledg/forge/config.py index 380a74c053..bc8c8ea1ee 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/config.py +++ b/dev_tools/pylibs/edgacknowledg/forge/config.py @@ -43,6 +43,21 @@ def read_secret(credential_name: str, return environ.get(env_name) +def _environment_secrets(environ: Mapping[str, str], + names: dict[str, str]) -> tuple[str, ...]: + '''The variables (of ``names``, credential name -> variable) a secret + is actually read from, for lack of a credential. + ''' + credentials_dir = environ.get('CREDENTIALS_DIRECTORY') + return tuple( + env_name for credential_name, env_name in names.items() + if environ.get(env_name) is not None and ( + credentials_dir is None or + not Path(credentials_dir, credential_name).is_file() + ) + ) + + @dataclass(frozen = True) class ForgeServiceConfig: '''Settings shared by every forge service.''' @@ -84,6 +99,9 @@ class ForgeServiceConfig: acknowledg_api_key: Optional[str] acknowledg_ui_host: str acknowledg_ui_port: int + # Secrets read from environment variables rather than credentials; any + # process can read its own initial environment (/proc/self/environ). + environment_secrets: tuple[str, ...] = () @classmethod def from_environment(cls, @@ -195,6 +213,13 @@ def get_list(name: str) -> tuple[str, ...]: ), acknowledg_ui_host = default_ui_host(), acknowledg_ui_port = default_ui_port(), + # (Not the database URL, which is only a secret if it holds a + # password, and then the documentation says to use the credential.) + environment_secrets = _environment_secrets(environ, { + 'forge-token': f"{ENV_PREFIX}TOKEN", + 'webhook-secret': f"{ENV_PREFIX}WEBHOOK_SECRET", + 'acknowledg-api-key': 'EDG_ACKNOWLEDG_API_KEY', + }), ) @property diff --git a/dev_tools/pylibs/edgacknowledg/forge/processor.py b/dev_tools/pylibs/edgacknowledg/forge/processor.py index c4d40f8bb6..a9c973d1e3 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/processor.py +++ b/dev_tools/pylibs/edgacknowledg/forge/processor.py @@ -48,12 +48,15 @@ from edgacknowledg.forge.sandbox import Sandbox from edgacknowledg.forge.workspace import ( BENCH_BASELINE_PATTERNS, + WorkspaceError, Workspaces, changes_patterns, + check_plain_tree, ensure_real_directory, extract_artifact, failures_patterns, remove_tree, + require_real_directory, ) _log = logging.getLogger(__name__) @@ -396,6 +399,19 @@ async def _changes_section(self, f"edg-rematerialize-test-run {reason}: " f"{result.output.strip()[-2000:]}" ) + # The sandboxed command saw only pull request content and the frozen + # tooling, but what it wrote is read outside the sandbox: it may hold + # only directories and regular files (no links to service files). + try: + await asyncio.to_thread( + check_plain_tree, + staging, + allow_hard_links = False + ) + except WorkspaceError as ex: + raise ProcessingError( + f"edg-rematerialize-test-run wrote something unsafe: {ex}" + ) from ex if len(edgtest.find_changes_files(staging / timestamp)) == 0: _log.warning( '%s#%d: edg-rematerialize-test-run produced no changes.elist; ' @@ -640,8 +656,14 @@ def _bench_sections(self, sections = [] for subdirectory, tag, name in BENCH_SECTIONS: root = run_dir / subdirectory - baseline_dir = bench_home / 'baselines' / 'project' / tag - if not root.is_dir() or not baseline_dir.is_dir(): + try: + # The baselines come from the pull request; never follow a link. + baseline_dir = require_real_directory( + bench_home, + PurePosixPath('baselines', 'project', tag) + ) + require_real_directory(run_dir, PurePosixPath(subdirectory)) + except WorkspaceError: continue sections.append(SectionRecord( kind = KIND_BENCH, diff --git a/dev_tools/pylibs/edgacknowledg/forge/publisher.py b/dev_tools/pylibs/edgacknowledg/forge/publisher.py index 87a4cccc0a..1aa9c71341 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/publisher.py +++ b/dev_tools/pylibs/edgacknowledg/forge/publisher.py @@ -3,15 +3,27 @@ # See https://edgcpp.org/LICENSE.txt for license information. # SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception -'''Publishing review sections to AcknowlEDG over one batch connection.''' +'''Publishing review sections to AcknowlEDG over one batch connection. + +The service publishes through ``SubprocessPublisher``: the handlers that +answer reviewers read pull request content, so they run in a separate, +sandboxed process (``publisher_worker``, wrapping ``AcknowledgPublisher``) +that sees only the review trees, read-only, and holds no secret but the +AcknowlEDG API key. +''' import asyncio +import importlib.util +import json import logging +import os +import sys +import time import uuid from dataclasses import dataclass from pathlib import Path -from typing import Optional, Protocol, Sequence +from typing import Any, Optional, Protocol, Sequence from edgacknowledg.client import ( DEFAULT_OPEN_TIMEOUT_S, @@ -28,7 +40,8 @@ ) from edgacknowledg.client.test import TestSourceHandler from edgacknowledg.forge.db import SectionRecord -from edgacknowledg.forge.sandbox import Sandbox +from edgacknowledg.forge.sandbox import Sandbox, SandboxUnavailableError +from edgacknowledg.forge.workspace import require_real_directory_under _log = logging.getLogger(__name__) @@ -86,7 +99,12 @@ class _ServedSection: class AcknowledgPublisher: - '''Serves sections with the stock AcknowlEDG handlers, read-only.''' + '''Serves sections with the stock AcknowlEDG handlers, read-only. + + The handlers treat the trees as untrusted (``Sandbox.context_for``): they + follow no symlinks, and with ``reviews_dir`` every section's directories + must be real directories under it. + ''' def __init__(self, *, @@ -95,7 +113,8 @@ def __init__(self, api_key: Optional[str], ui_host: str, ui_port: int, - sandbox: Sandbox) -> None: + sandbox: Sandbox, + reviews_dir: Optional[Path] = None) -> None: self._session = BatchClientSession( status_listener = self, api_host = api_host, @@ -106,6 +125,7 @@ def __init__(self, self._ui_host = ui_host self._ui_port = ui_port self._sandbox = sandbox + self._reviews_dir = reviews_dir self._connected = asyncio.Event() self._lock = asyncio.Lock() self._reviews: dict[uuid.UUID, ReviewClient] = {} @@ -151,25 +171,38 @@ def section_url(self, section.slug ) + def _check_directories(self, section: SectionRecord) -> None: + '''Require the section's directories to be real ones (no symlink + anywhere below the reviews directory), the root and baselines inside + the home; bubblewrap and the handlers rely on that. + ''' + if self._reviews_dir is None: + return + directories = [section.home, section.root] + if 'baseline_dir' in section.details: + directories.append(Path(section.details['baseline_dir'])) + for directory in directories: + require_real_directory_under(self._reviews_dir, directory) + for directory in directories[1:]: + require_real_directory_under(section.home, directory) + def _build_handler(self, section: SectionRecord) -> ReviewClientHandler: - # Commands run on reviewers' behalf (ifc-dump, cg_annotate) only see the - # section's tree, read-only. - command_prefix = self._sandbox.prefix(read_only = [section.home]) + self._check_directories(section) + # Files are read without following links, and commands run on reviewers' + # behalf (ifc-dump, cg_annotate) only see the section's tree, read-only. + context = self._sandbox.context_for(section.home) if section.kind in (KIND_TEST_CHANGES, KIND_TEST_FAILURES): return TestSourceHandler( section.name, section.slug, - section.home, - section.root, - command_prefix = command_prefix + context.subpath(section.home), + context.subpath(section.root) ) if section.kind == KIND_BENCH: session = BenchReviewSession( - bench_home = section.home, - run_dir = section.root, - baseline_dir = Path(section.details['baseline_dir']), - tag = section.details['tag'], - command_prefix = command_prefix + run_dir = context.subpath(section.root), + baseline_dir = context.subpath(Path(section.details['baseline_dir'])), + tag = section.details['tag'] ) return BenchSourceHandler( section.name, @@ -241,3 +274,396 @@ async def _remove(self, review_id: uuid.UUID) -> None: self._served.pop(review_id, None) if client_review is not None: await self._session.remove_review(client_review) + + +# Messages between ``SubprocessPublisher`` and ``publisher_worker``: one JSON +# object per line. The parent sends one initialization message (settings and +# the API key, kept out of the worker's command line and environment), then +# requests ({'id', 'op': 'sync' | 'remove', ...}); the worker answers each +# ({'id', 'ok': true} or {'id', 'error': 'unavailable' | 'failed', +# 'message'}) and reports its connection ({'event': 'connected' | +# 'disconnected'}). + +WORKER_MODULE = 'edgacknowledg.forge.publisher_worker' + +# Longest message line (a sync of every section of a review is small; this +# is a sanity limit). +MESSAGE_LIMIT = 16 * 1024 * 1024 + + +def encode_review(review: ReviewIdentity) -> dict[str, Any]: + return { + 'review_id': str(review.review_id), + 'name': review.name, + 'timestamp': review.timestamp, + } + + +def decode_review(value: dict[str, Any]) -> ReviewIdentity: + return ReviewIdentity( + review_id = uuid.UUID(value['review_id']), + name = str(value['name']), + timestamp = str(value['timestamp']) + ) + + +def encode_section(section: SectionRecord) -> dict[str, Any]: + return { + 'kind': section.kind, + 'name': section.name, + 'slug': section.slug, + 'source_key': section.source_key, + 'content_fingerprint': section.content_fingerprint, + 'root': str(section.root), + 'home': str(section.home), + 'details': section.details, + } + + +def decode_section(value: dict[str, Any]) -> SectionRecord: + return SectionRecord( + kind = str(value['kind']), + name = str(value['name']), + slug = str(value['slug']), + source_key = str(value['source_key']), + content_fingerprint = str(value['content_fingerprint']), + root = Path(value['root']), + home = Path(value['home']), + details = dict(value['details']), + ) + + +def _python_paths() -> list[Path]: + '''The directories this interpreter loads code from (which the worker + needs to see): its installation, the virtual environment, ``sys.path``, + and wherever the EDG packages actually live (e.g. an editable install's + sources). + ''' + paths = [Path(sys.prefix), Path(sys.base_prefix)] + # Every link on the way to the interpreter must resolve in the sandbox + # too (e.g. a virtual environment's bin/python, or a chain of them). + executable = sys.executable + for _ in range(40): + paths.append(Path(executable).parent) + if not os.path.islink(executable): + break + executable = os.path.join( + os.path.dirname(executable), + os.readlink(executable) + ) + paths += [Path(entry) for entry in sys.path if entry != ''] + for name in ('edgacknowledg', 'edgtest', 'edgutil'): + spec = importlib.util.find_spec(name) + if spec is not None and spec.submodule_search_locations is not None: + paths += [Path(location).parent + for location in spec.submodule_search_locations] + result: list[Path] = [] + for path in paths: + path = path.absolute() + if path.is_dir() and path not in result: + result.append(path) + return result + + +def _outermost(paths: Sequence[Path]) -> list[Path]: + '''``paths`` without those inside another of them.''' + return [ + path for path in paths + if not any(other != path and path.is_relative_to(other) + for other in paths) + ] + + +class SubprocessPublisher: + '''Publishes through a ``publisher_worker`` process it supervises. + + The worker runs under ``sandbox.service_prefix``: it sees ``reviews_dir``, + the frozen tools, and the Python installation, all read-only, and has the + network (for AcknowlEDG), but not the service's home, database, + credentials, or clones. Should it exit, it is restarted and every + review's sections are served again. + ''' + + def __init__(self, + *, + api_host: str, + api_port: int, + api_key: Optional[str], + ui_host: str, + ui_port: int, + sandbox: Sandbox, + reviews_dir: Path, + home: Path, + request_timeout: float = 15 * 60.0, + restart_delay: float = 1.0, + max_restart_delay: float = 60.0) -> None: + self._api_host = api_host + self._api_port = api_port + self._api_key = api_key + self._ui_host = ui_host + self._ui_port = ui_port + self._sandbox = sandbox + self._reviews_dir = reviews_dir + self._home = home + self._request_timeout = request_timeout + self._restart_delay = restart_delay + self._max_restart_delay = max_restart_delay + + self._process: Optional[asyncio.subprocess.Process] = None + self._connected = asyncio.Event() + # Serializes requests, and replaying after a restart. + self._lock = asyncio.Lock() + self._next_id = 0 + self._pending: dict[int, asyncio.Future[None]] = {} + # What each review should serve, to replay to a restarted worker. + self._desired: dict[uuid.UUID, + tuple[ReviewIdentity, list[SectionRecord]]] = {} + self._needs_replay = False + self._replay_task: Optional[asyncio.Task[None]] = None + + # Publisher + + async def wait_connected(self) -> None: + await self._connected.wait() + + def section_url(self, + review_id: uuid.UUID, + section: SectionRecord) -> Optional[str]: + return form_section_http_url( + self._ui_host, + self._ui_port, + review_id, + _SUBSCRIPTION_TYPES[section.kind], + section.slug + ) + + async def sync(self, + review: ReviewIdentity, + sections: Sequence[SectionRecord]) -> None: + async with self._lock: + if not self._connected.is_set(): + raise PublisherUnavailableError('not connected to AcknowlEDG') + await self._sync(review, sections) + + async def remove(self, review_id: uuid.UUID) -> None: + async with self._lock: + # A restarted worker serves nothing for it either way. + self._desired.pop(review_id, None) + if self._process is not None: + await self._request({'op': 'remove', 'review_id': str(review_id)}) + + async def _sync(self, + review: ReviewIdentity, + sections: Sequence[SectionRecord]) -> None: + await self._request({ + 'op': 'sync', + 'review': encode_review(review), + 'sections': [encode_section(section) for section in sections], + }) + if len(sections) == 0: + self._desired.pop(review.review_id, None) + else: + self._desired[review.review_id] = (review, list(sections)) + + # Worker requests + + async def _request(self, request: dict[str, Any]) -> None: + process = self._process + if process is None or process.stdin is None: + raise PublisherUnavailableError('the publisher worker is not running') + self._next_id += 1 + request_id = self._next_id + future: asyncio.Future[None] = ( + asyncio.get_running_loop().create_future() + ) + self._pending[request_id] = future + try: + line = json.dumps({'id': request_id, **request}) + '\n' + try: + process.stdin.write(line.encode()) + await process.stdin.drain() + except (BrokenPipeError, ConnectionResetError) as ex: + raise PublisherUnavailableError( + f"the publisher worker went away: {ex}" + ) from ex + try: + await asyncio.wait_for(asyncio.shield(future), + self._request_timeout) + except asyncio.TimeoutError: + _log.error('the publisher worker did not answer within %s s; ' + 'restarting it', self._request_timeout) + self._kill(process) + raise PublisherUnavailableError( + 'the publisher worker stopped answering' + ) from None + finally: + self._pending.pop(request_id, None) + + def _handle_message(self, line: bytes) -> None: + try: + value = json.loads(line) + except ValueError: + _log.error('bad message from the publisher worker: %r', line[:200]) + return + event = value.get('event') + if event == 'connected': + if self._replay_task is None or self._replay_task.done(): + self._replay_task = asyncio.create_task(self._on_connected()) + return + if event == 'disconnected': + self._connected.clear() + return + future = self._pending.get(value.get('id')) + if future is None or future.done(): + return + error = value.get('error') + if error is None: + future.set_result(None) + elif error == 'unavailable': + future.set_exception( + PublisherUnavailableError(str(value.get('message'))) + ) + else: + future.set_exception( + RuntimeError(f"publisher worker: {value.get('message')}") + ) + + async def _on_connected(self) -> None: + '''Once the worker is connected, serve again what a previous worker + served; only then accept new requests. + ''' + async with self._lock: + if self._needs_replay: + for review, sections in list(self._desired.values()): + try: + await self._sync(review, sections) + except PublisherUnavailableError as ex: + _log.warning('serving the reviews again failed (%s); will ' + 'retry on the next connection', ex) + return + except Exception: + # Don't let one review hold up every other one; it is served + # again once its pull request changes. + _log.exception('serving review %s again failed; dropping it', + review.review_id) + self._desired.pop(review.review_id, None) + self._needs_replay = False + _log.info('connected to AcknowlEDG') + self._connected.set() + + # Supervision + + def _worker_command(self) -> list[str]: + python_paths = _python_paths() + command = [sys.executable, '-m', WORKER_MODULE] + env = { + 'HOME': '/tmp', + 'PYTHONUTF8': '1', + 'PYTHONDONTWRITEBYTECODE': '1', + 'PYTHONPATH': os.pathsep.join(str(path) for path in python_paths), + } + if not self._sandbox.enabled: + return command + binds = _outermost([*python_paths, self._reviews_dir]) + for path in binds: + # The worker must never see the service's own files. + if path != self._reviews_dir and (self._home.is_relative_to(path) or + path.is_relative_to(self._home)): + raise SandboxUnavailableError( + f"the publisher worker would see the service's home " + f"({self._home}) through the Python path entry {path}" + ) + return [ + *self._sandbox.service_prefix(read_only = binds, env = env), + *command, + ] + + def _worker_env(self) -> Optional[dict[str, str]]: + if self._sandbox.enabled: + # bubblewrap sets the worker's environment; it needs none itself. + return {'PATH': os.environ.get('PATH', '/usr/bin:/bin')} + return { + 'PATH': f"{self._sandbox.search_path}:{os.environ.get('PATH', '')}", + 'HOME': os.environ.get('HOME', '/tmp'), + 'PYTHONUTF8': '1', + 'PYTHONPATH': os.pathsep.join(str(path) for path in _python_paths()), + } + + def _init_message(self) -> dict[str, Any]: + return { + 'api_host': self._api_host, + 'api_port': self._api_port, + 'api_key': self._api_key, + 'ui_host': self._ui_host, + 'ui_port': self._ui_port, + 'reviews_dir': str(self._reviews_dir), + 'sandbox': { + 'enabled': self._sandbox.enabled, + 'tools_dir': ( + str(self._sandbox.tools_dir) + if self._sandbox.tools_dir is not None else None + ), + 'bwrap': self._sandbox.bwrap, + }, + } + + @staticmethod + def _kill(process: asyncio.subprocess.Process) -> None: + if process.returncode is None: + try: + process.kill() + except ProcessLookupError: + pass + + async def run(self) -> None: + '''Run the worker, restarting it whenever it exits, until cancelled.''' + self._reviews_dir.mkdir(parents = True, exist_ok = True) + delay = self._restart_delay + while True: + started = time.monotonic() + try: + await self._run_worker() + except asyncio.CancelledError: + raise + except Exception: + _log.exception('the publisher worker failed') + if time.monotonic() - started > self._max_restart_delay: + delay = self._restart_delay + _log.warning('the publisher worker exited; restarting in %.0f s', + delay) + await asyncio.sleep(delay) + delay = min(delay * 2, self._max_restart_delay) + + async def _run_worker(self) -> None: + process = await asyncio.create_subprocess_exec( + *self._worker_command(), + stdin = asyncio.subprocess.PIPE, + stdout = asyncio.subprocess.PIPE, + env = self._worker_env(), + limit = MESSAGE_LIMIT + ) + assert process.stdin is not None and process.stdout is not None + self._process = process + try: + process.stdin.write( + (json.dumps(self._init_message()) + '\n').encode() + ) + await process.stdin.drain() + while True: + line = await process.stdout.readline() + if line == b'': + break + self._handle_message(line) + finally: + self._process = None + self._connected.clear() + self._needs_replay = True + for future in self._pending.values(): + if not future.done(): + future.set_exception( + PublisherUnavailableError('the publisher worker exited') + ) + self._kill(process) + await process.wait() + if self._replay_task is not None: + self._replay_task.cancel() diff --git a/dev_tools/pylibs/edgacknowledg/forge/publisher_worker.py b/dev_tools/pylibs/edgacknowledg/forge/publisher_worker.py new file mode 100644 index 0000000000..36e20aea2e --- /dev/null +++ b/dev_tools/pylibs/edgacknowledg/forge/publisher_worker.py @@ -0,0 +1,148 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''The process serving reviewers: an ``AcknowledgPublisher`` driven by +``SubprocessPublisher`` over stdin/stdout (see ``publisher`` for the +messages). + +It answers reviewers' requests by reading pull request content, so the +service runs it sandboxed, away from its credentials, database, and clones. +''' + +import asyncio +import json +import logging +import os +import sys +import uuid + +from pathlib import Path +from typing import Any, BinaryIO, Optional + +from edgacknowledg.forge.publisher import ( + MESSAGE_LIMIT, + AcknowledgPublisher, + PublisherUnavailableError, + decode_review, + decode_section, +) +from edgacknowledg.forge.sandbox import Sandbox + +_log = logging.getLogger('edgacknowledg.forge.publisher_worker') + + +class _Output: + '''Writes messages to the parent, one JSON object per line.''' + + def __init__(self, stream: BinaryIO) -> None: + self._stream = stream + + def send(self, value: dict[str, Any]) -> None: + self._stream.write((json.dumps(value) + '\n').encode()) + self._stream.flush() + + +class _WorkerPublisher(AcknowledgPublisher): + '''Also reports the AcknowlEDG connection to the parent.''' + + def __init__(self, output: _Output, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._output = output + + def connected(self) -> None: + super().connected() + self._output.send({'event': 'connected'}) + + def disconnected(self) -> None: + super().disconnected() + self._output.send({'event': 'disconnected'}) + + +async def _handle(publisher: AcknowledgPublisher, + request: dict[str, Any]) -> None: + operation = request.get('op') + if operation == 'sync': + await publisher.sync( + decode_review(request['review']), + [decode_section(section) for section in request['sections']] + ) + elif operation == 'remove': + await publisher.remove(uuid.UUID(request['review_id'])) + else: + raise ValueError(f"unknown operation: {operation!r}") + + +async def _serve_requests(reader: asyncio.StreamReader, + publisher: AcknowledgPublisher, + output: _Output) -> None: + '''Answer requests, one at a time, until the parent closes stdin.''' + while True: + line = await reader.readline() + if line == b'': + return + request = json.loads(line) + reply: dict[str, Any] = {'id': request.get('id')} + try: + await _handle(publisher, request) + reply['ok'] = True + except PublisherUnavailableError as ex: + reply.update(error = 'unavailable', message = str(ex)) + except Exception as ex: + _log.exception('request %s failed', request.get('op')) + reply.update(error = 'failed', message = f"{type(ex).__name__}: {ex}") + output.send(reply) + + +async def _run(output: _Output) -> None: + loop = asyncio.get_running_loop() + reader = asyncio.StreamReader(limit = MESSAGE_LIMIT) + await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(reader), + sys.stdin + ) + settings = json.loads(await reader.readline()) + sandbox_settings = settings['sandbox'] + tools_dir: Optional[str] = sandbox_settings['tools_dir'] + publisher = _WorkerPublisher( + output, + api_host = settings['api_host'], + api_port = int(settings['api_port']), + api_key = settings['api_key'], + ui_host = settings['ui_host'], + ui_port = int(settings['ui_port']), + sandbox = Sandbox( + enabled = bool(sandbox_settings['enabled']), + tools_dir = Path(tools_dir) if tools_dir is not None else None, + bwrap = sandbox_settings['bwrap'] + ), + reviews_dir = Path(settings['reviews_dir']) + ) + del settings + + publisher_task = asyncio.create_task(publisher.run()) + try: + await _serve_requests(reader, publisher, output) + finally: + publisher_task.cancel() + await asyncio.gather(publisher_task, return_exceptions = True) + + +def main() -> None: + # Messages go out on a private copy of stdout; anything else printing to + # stdout (the client libraries print) ends up on stderr, with the logs. + output = _Output(os.fdopen(os.dup(sys.stdout.fileno()), 'wb')) + os.dup2(sys.stderr.fileno(), sys.stdout.fileno()) + logging.basicConfig( + level = logging.INFO, + format = '%(levelname)s %(name)s (publisher worker): %(message)s' + ) + try: + asyncio.run(_run(output)) + except KeyboardInterrupt: + pass + + +if __name__ == '__main__': + main() diff --git a/dev_tools/pylibs/edgacknowledg/forge/sandbox.py b/dev_tools/pylibs/edgacknowledg/forge/sandbox.py index 3f1f1b9f08..0a068b305f 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/sandbox.py +++ b/dev_tools/pylibs/edgacknowledg/forge/sandbox.py @@ -6,13 +6,22 @@ '''Run commands over untrusted pull request content under bubblewrap. Sandboxed commands see a read-only /usr (plus the host's /bin, /lib* -layout), only the paths they are given, no network, and a cleared -environment, so neither the service's secrets nor the rest of the host are -reachable from them. +layout), only the paths they are given, no network, no capabilities, and a +cleared environment, so neither the service's secrets nor the rest of the +host are reachable from them; nor can they create user namespaces of their +own (hence bubblewrap 0.8 or later, for --disable-userns). + +The process serving reviewers (see ``publisher_worker``) runs sandboxed +too, through ``service_prefix``: like a command, but with the network (to +reach AcknowlEDG) and able to start sandboxed commands itself. + +bubblewrap follows symlinks in the paths it binds, so every path given to +it must be one the service created or checked (``require_real_directory``). ''' import asyncio import os +import re import shutil import signal @@ -20,12 +29,33 @@ from pathlib import Path from typing import Mapping, Optional, Sequence +from edgacknowledg.client.shared import SandboxContext + # Top-level directories programs need, mirrored as the host has them # (directories or, on merged-/usr systems, symlinks into /usr). _SYSTEM_DIRS = ('/bin', '/sbin', '/lib', '/lib32', '/lib64', '/libx32') _DEFAULT_PATH = '/usr/local/bin:/usr/bin:/bin' +# The oldest bubblewrap with --disable-userns (Ubuntu 24.04 and later, and +# current Fedora, ship newer ones). +MIN_BWRAP_VERSION = (0, 8) + +_BWRAP_VERSION = re.compile(r'^bubblewrap (\d+)\.(\d+)', re.MULTILINE) + +# What a networked process needs from /etc: name resolution and the +# certificates (and crypto policy) for TLS. +_NETWORK_ETC = ( + '/etc/resolv.conf', + '/etc/hosts', + '/etc/nsswitch.conf', + '/etc/host.conf', + '/etc/gai.conf', + '/etc/ssl', + '/etc/pki', + '/etc/crypto-policies', +) + class SandboxUnavailableError(Exception): '''Raised when sandboxing is required but bubblewrap can't be used.''' @@ -64,6 +94,14 @@ def __init__(self, def enabled(self) -> bool: return self._enabled + @property + def tools_dir(self) -> Optional[Path]: + return self._tools_dir + + @property + def bwrap(self) -> str: + return self._bwrap + @property def search_path(self) -> str: if self._tools_dir is None: @@ -85,6 +123,27 @@ async def check(self) -> None: f"'{self._bwrap}' was not found; install bubblewrap (or, for local " f"development only, disable sandboxing)" ) + result = await self._exec( + [self._bwrap, '--version'], + process_env = None, + cwd = None, + timeout = 30 + ) + match = _BWRAP_VERSION.search(result.output) + version = ( + (int(match.group(1)), int(match.group(2))) if match is not None + else None + ) + if version is None or version < MIN_BWRAP_VERSION: + found = ( + f"{version[0]}.{version[1]}" if version is not None + else f"an unknown version ({result.output.strip()!r})" + ) + raise SandboxUnavailableError( + f"bubblewrap {MIN_BWRAP_VERSION[0]}.{MIN_BWRAP_VERSION[1]} or later " + f"is required (for --disable-userns), but '{self._bwrap}' is {found}" + ) + result = await self.run(['true'], timeout = 30) if not result.succeeded: raise SandboxUnavailableError( @@ -92,6 +151,20 @@ async def check(self) -> None: f"{result.output.strip()}" ) + # Commands for reviewers run sandboxed inside the sandboxed process + # serving them. + result = await self._exec( + [*self.service_prefix(), *self.prefix(), 'true'], + process_env = None, + cwd = None, + timeout = 30 + ) + if not result.succeeded: + raise SandboxUnavailableError( + f"bubblewrap can't run nested in itself (are nested user " + f"namespaces allowed?): {result.output.strip()}" + ) + def prefix(self, *, read_only: Sequence[Path] = (), @@ -106,11 +179,57 @@ def prefix(self, if not self._enabled: return [] + # (--disable-userns needs the user namespace unshared explicitly.) + args = [self._bwrap, '--unshare-all', '--unshare-user', '--disable-userns'] + args += self._common_args(read_only = read_only) + for path in writable: + args += ['--bind', str(path), str(path)] + args += self._env_args({**self._base_env(), **env}) + if cwd is not None: + args += ['--chdir', str(cwd)] + args.append('--') + return args + + def context_for(self, *roots: Path) -> SandboxContext: + '''The context for serving the pull request content under ``roots``: + no symlink in it is followed, and commands run on it see only it, + read-only. + ''' + # Bind the roots as the context reduces them. + context = SandboxContext(*roots, allow_symlinks = False) + return SandboxContext( + *context.roots, + allow_symlinks = False, + command_prefix = self.prefix(read_only = context.roots) + ) + + def service_prefix(self, + *, + read_only: Sequence[Path] = (), + env: Mapping[str, str] = {}) -> list[str]: + '''Return the command prefix for a long-lived process that handles + untrusted content on the network's behalf: it may use the network and + start sandboxed commands (``prefix``) of its own, but otherwise sees only + ``read_only``, read-only. + + Unlike ``prefix``, nothing is added to the environment but ``PATH``. + ''' + if not self._enabled: + return [] + + args = [self._bwrap, '--unshare-all', '--share-net'] + args += self._common_args(read_only = read_only) + for path in _NETWORK_ETC: + args += ['--ro-bind-try', path, path] + args += self._env_args(env) + args.append('--') + return args + + def _common_args(self, *, read_only: Sequence[Path]) -> list[str]: args = [ - self._bwrap, - '--unshare-all', '--die-with-parent', '--new-session', + '--cap-drop', 'ALL', '--ro-bind', '/usr', '/usr', '--ro-bind-try', '/etc/alternatives', '/etc/alternatives', '--ro-bind-try', '/etc/ld.so.cache', '/etc/ld.so.cache', @@ -126,15 +245,12 @@ def prefix(self, tools = [self._tools_dir] if self._tools_dir is not None else [] for path in [*tools, *read_only]: args += ['--ro-bind', str(path), str(path)] - for path in writable: - args += ['--bind', str(path), str(path)] + return args - args += ['--clearenv', '--setenv', 'PATH', self.search_path] - for name, value in {**self._base_env(), **env}.items(): + def _env_args(self, env: Mapping[str, str]) -> list[str]: + args = ['--clearenv', '--setenv', 'PATH', self.search_path] + for name, value in env.items(): args += ['--setenv', name, value] - if cwd is not None: - args += ['--chdir', str(cwd)] - args.append('--') return args async def run(self, @@ -165,13 +281,25 @@ async def run(self, 'HOME': os.environ.get('HOME', '/tmp'), **env, } + return await self._exec( + args, + process_env = process_env, + cwd = None if self._enabled else cwd, + timeout = timeout + ) + async def _exec(self, + args: Sequence[str], + *, + process_env: Optional[dict[str, str]], + cwd: Optional[Path], + timeout: float) -> CommandResult: process = await asyncio.create_subprocess_exec( *args, stdin = asyncio.subprocess.DEVNULL, stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.STDOUT, - cwd = None if self._enabled else cwd, + cwd = cwd, env = process_env, start_new_session = True ) diff --git a/dev_tools/pylibs/edgacknowledg/forge/service.py b/dev_tools/pylibs/edgacknowledg/forge/service.py index 5b97d49097..28e6efc6ae 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/service.py +++ b/dev_tools/pylibs/edgacknowledg/forge/service.py @@ -8,7 +8,8 @@ A forge service supplies its ``Forge`` (and optionally ``ForgeWebhooks`` and ``ForgeCommenter``) via ``main``; this module runs the rest: -- the AcknowlEDG publisher (one batch connection), +- the AcknowlEDG publisher (one batch connection, in a sandboxed worker + process), - the processor (and, if enabled, the announcer after each sweep), - the poller (always: at least the startup sync), - the webhook receiver and its event worker (only if configured). @@ -29,7 +30,7 @@ from edgacknowledg.forge.ingest import Ingestor from edgacknowledg.forge.poller import run_poller from edgacknowledg.forge.processor import Processor -from edgacknowledg.forge.publisher import AcknowledgPublisher +from edgacknowledg.forge.publisher import SubprocessPublisher from edgacknowledg.forge.sandbox import Sandbox, SandboxUnavailableError from edgacknowledg.forge.webhooks import EventQueue, create_app from edgacknowledg.forge.workspace import Workspaces @@ -86,18 +87,25 @@ async def run_service(config: ForgeServiceConfig, await sandbox.check() if not sandbox.enabled: _log.warning('sandboxing is DISABLED; for local development only') + elif len(config.environment_secrets) != 0: + _log.warning('secrets come from the environment (%s); use credentials ' + 'in production', ', '.join(config.environment_secrets)) store = await ForgeStore.open(config.database_url) try: wake = asyncio.Event() ingestor = Ingestor(store, forge, config, wake = wake) - publisher = AcknowledgPublisher( + workspaces = Workspaces(config.home, forge) + # Serving reviewers happens in a separate, sandboxed process. + publisher = SubprocessPublisher( api_host = config.acknowledg_api_host, api_port = config.acknowledg_api_port, api_key = config.acknowledg_api_key, ui_host = config.acknowledg_ui_host, ui_port = config.acknowledg_ui_port, - sandbox = sandbox + sandbox = sandbox, + reviews_dir = workspaces.reviews_dir, + home = config.home ) processor: Optional[Processor] = None @@ -119,7 +127,7 @@ async def run_service(config: ForgeServiceConfig, store = store, forge = forge, config = config, - workspaces = Workspaces(config.home, forge), + workspaces = workspaces, sandbox = sandbox, publisher = publisher, wake = wake, diff --git a/dev_tools/pylibs/edgacknowledg/forge/workspace.py b/dev_tools/pylibs/edgacknowledg/forge/workspace.py index 394f75da1e..9b1cf5172f 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/workspace.py +++ b/dev_tools/pylibs/edgacknowledg/forge/workspace.py @@ -14,9 +14,14 @@ only when a section needs them. - Run directories are swapped in by rename and deleted once superseded. -Everything taken from a pull request or a CI artifact is untrusted: paths -are checked for symlinks before the service writes through them, and -archives are size-capped and may not contain links or escaping paths. +Everything taken from a pull request or a CI artifact is untrusted: + +- Clones check symlinks out as plain files holding the link's target + (``core.symlinks=false``) and fsck what they fetch, so no worktree holds a + link a pull request committed; ``check_plain_tree`` verifies that after + every checkout, and checks what sandboxed commands write too. +- Paths are checked for symlinks before the service writes through them. +- Archives are size-capped and may not contain links or escaping paths. ''' import asyncio @@ -189,6 +194,61 @@ def ensure_real_directory(root: Path, relative: PurePosixPath) -> Path: return current +def require_real_directory(root: Path, relative: PurePosixPath) -> Path: + '''Return ``root/relative`` after checking that every component of it + below ``root`` is a real directory (not a symlink); create nothing. + ''' + current = root + for part in relative.parts: + if part in ('', '.', '..'): + raise WorkspaceError(f"invalid path component in {relative}") + current = current / part + try: + mode = os.lstat(current).st_mode + except FileNotFoundError: + raise WorkspaceError(f"{current} does not exist") from None + if not stat.S_ISDIR(mode): + raise WorkspaceError(f"{current} is not a real directory") + return current + + +def require_real_directory_under(root: Path, path: Path) -> Path: + '''``require_real_directory`` for an absolute ``path`` that must lie + (lexically) under ``root``. + ''' + try: + relative = path.relative_to(root) + except ValueError: + raise WorkspaceError(f"{path} is not under {root}") from None + return require_real_directory(root, PurePosixPath(*relative.parts)) + + +def check_plain_tree(root: Path, *, allow_hard_links: bool = True) -> None: + '''Raise ``WorkspaceError`` unless everything under ``root`` is a real + directory or a regular file: no symlinks, FIFOs, sockets, or devices + (and, unless ``allow_hard_links``, no regular file with other links). + + Run over trees that untrusted content shaped (worktrees, sandbox output) + before anything outside a sandbox reads them. + ''' + def check(path: Path) -> None: + info = os.lstat(path) + if stat.S_ISDIR(info.st_mode): + return + if not stat.S_ISREG(info.st_mode): + raise WorkspaceError(f"{path} is not a regular file or directory") + if not allow_hard_links and info.st_nlink > 1: + raise WorkspaceError(f"{path} is hard-linked") + + def raise_error(error: OSError) -> None: + raise error + + check(root) + for directory, dirs, files in os.walk(root, onerror = raise_error): + for name in [*dirs, *files]: + check(Path(directory, name)) + + def remove_tree(path: Path) -> None: '''Remove a directory tree (without following symlinks), if present.''' try: @@ -369,6 +429,12 @@ async def _ensure_clone(self, repository: str) -> Path: # Each worktree keeps its own sparse-checkout configuration. ('extensions.worktreeConfig', 'true'), ('core.hooksPath', os.devnull), + # Check symlinks out as plain files holding their target, so a pull + # request can't plant a link for the service to read through. + ('core.symlinks', 'false'), + # Reject malformed trees ('..' or '.git' components and the like). + ('transfer.fsckObjects', 'true'), + ('fetch.fsckObjects', 'true'), ('gc.autoDetach', 'false')): await self.git('config', key, value, git_dir = staging) # With per-worktree configuration, core.bare must be per-worktree too, @@ -447,7 +513,9 @@ async def checkout(self, '''Check ``sha`` out sparsely (only ``patterns``) into the review's ``name`` worktree, creating it if needed; return the worktree root. - Untracked run directories in the worktree are left alone. + Untracked run directories in the worktree are left alone. The result + holds only real directories and regular files (``WorkspaceError`` + otherwise). ''' async with self.lock(info.repository): clone_dir = await self._ensure_clone(info.repository) @@ -456,7 +524,7 @@ async def checkout(self, PurePosixPath(self._reviews_dir.name, str(review_id)) ) worktree = review_dir / name - if not worktree.exists(): + if not os.path.lexists(worktree): await self.git( 'worktree', 'add', '--quiet', '--no-checkout', '--detach', str(worktree), sha, @@ -468,6 +536,7 @@ async def checkout(self, stdin = ''.join(f"{pattern}\n" for pattern in patterns) ) await self.git('reset', '--quiet', '--hard', sha, cwd = worktree) + await asyncio.to_thread(check_plain_tree, worktree) return worktree async def remove_review(self, diff --git a/dev_tools/services/acknowledg-forge/README.md b/dev_tools/services/acknowledg-forge/README.md index e74362f14e..25a7bfe6ae 100644 --- a/dev_tools/services/acknowledg-forge/README.md +++ b/dev_tools/services/acknowledg-forge/README.md @@ -59,14 +59,40 @@ Pull requests, and the CI artifacts their runs produce, are untrusted: merge-base with a served branch (`ACKNOWLEDG_FORGE_BASE_BRANCHES`). - Every command that reads pull request content (`edg-rematerialize-test-run` and `patch`, plus `ifc-dump` and `cg_annotate` run for reviewers) runs under - [bubblewrap](https://github.com/containers/bubblewrap). It gets no network - and a cleared environment, sees only the files it needs, and can write only - its output directory. + [bubblewrap](https://github.com/containers/bubblewrap). It gets no network, + no capabilities, and a cleared environment, sees only the files it needs, + and can write only its output directory. It can't create user namespaces + either, so the service requires bubblewrap 0.8 or later and refuses to + start with an older one. Reviewer-triggered commands are also time-limited. +- Answering reviewers means reading pull request content, so it happens in a + separate process (`publisher_worker`), also under bubblewrap. That process + has the network, to reach AcknowlEDG. It sees the review trees, read-only, + and nothing else of the service: no credentials, database, or clones. Its + only secret is the AcknowlEDG API key, which it gets over a pipe. If it + dies, the service restarts it and serves every review again. At startup, + the service checks that bubblewrap can run nested this way. +- **Symlinks.** A pull request can commit symlinks. Several layers keep the + service from reading through them: + - Clones check symlinks out as plain files holding the link's target + (`core.symlinks=false`), so no worktree contains one. Symlinked tests + therefore show their target path rather than its contents. + - Clones also fsck everything fetched. + - After every checkout, a worktree may hold only directories and regular + files, and so may what `edg-rematerialize-test-run` writes (which also + may not hold hard links). A violation fails the section rather than + serving it. + - The section's directories, including the bench baselines a pull request + supplies, must be real directories. + - The handlers serving reviewers open files without following any symlink, + and only under the section's tree. + - The service refuses to write through symlinks. - Artifacts are size-capped, including the decompressed size of the `.rt.tar.gz` files inside them, and may not contain links or escaping paths. - The service refuses to write through symlinks a pull request committed. - Sections are read-only. Reviewers can't accept diffs or update baselines through the service. +- Secrets belong in credentials. A secret passed in an environment variable is + readable from the process's own `/proc/self/environ`, so the service warns + at startup when sandboxing is on and a secret came from the environment. - Comments contain only fixed text, commit hashes, and links the service generates. @@ -84,9 +110,9 @@ The host then needs only rootless Podman (with Quadlet), systemd, and network access to the forge and the AcknowlEDG API. Running directly on a host instead needs Python 3.11 or later, git 2.35 or -later, bubblewrap (with unprivileged user namespaces), `python3`, `patch`, -`diff` and `cg_annotate` under `/usr`, and a `dev_tools` copy for -`ACKNOWLEDG_FORGE_TOOLS_DIR`. +later, bubblewrap 0.8 or later with unprivileged user namespaces (Ubuntu +24.04 and later have it), `python3`, `patch`, `diff` and `cg_annotate` under +`/usr`, and a `dev_tools` copy for `ACKNOWLEDG_FORGE_TOOLS_DIR`. ## Configuration @@ -411,9 +437,10 @@ settings so the host can pull it without logging in. Notes: - **Isolation:** the container keeps the service and its dependencies away - from the host. bubblewrap still isolates the commands that read pull request - content from the service inside the container: from its credentials, its - database, and other reviews. `unmask=/proc/*` only affects what the + from the host. Inside the container, bubblewrap still isolates whatever + reads pull request content from the service: from its credentials, its + database, and its clones. That covers the commands, and the worker process + that answers reviewers. The commands are also isolated from other reviews. `unmask=/proc/*` only affects what the container's `/proc` shows. In a rootless container those files stay unreadable to it. - **Shutdown:** stopping the service (SIGTERM) shuts it down cleanly. Because diff --git a/dev_tools/services/acknowledg-forge/tests/test_acknowledg_integration.py b/dev_tools/services/acknowledg-forge/tests/test_acknowledg_integration.py index 99f7369612..d92bd3800d 100644 --- a/dev_tools/services/acknowledg-forge/tests/test_acknowledg_integration.py +++ b/dev_tools/services/acknowledg-forge/tests/test_acknowledg_integration.py @@ -6,6 +6,7 @@ '''AcknowledgPublisher against a real AcknowlEDG server.''' import asyncio +import json import os import shutil import signal @@ -20,7 +21,13 @@ import pytest_asyncio import websockets -from edgacknowledg import decode_response, encode_message, message +from edgacknowledg import ( + PROTOCOL_REVISION, + batch_event_message, + decode_response, + encode_message, + message, +) from edgacknowledg.forge.db import SectionRecord from edgacknowledg.forge.publisher import ( KIND_TEST_CHANGES, @@ -154,9 +161,8 @@ async def test_publish_heal_and_survive_bad_requests( f"https://ui.example/review/{review.review_id}/tests/test-changes" ) - # A reviewer asking for a file outside the test home is refused (the - # provider logs it; a bare 'invalid' can't be routed back to the - # reviewer), and the section keeps answering afterwards. + # A reviewer asking for a file outside the test home is reported by the + # provider and disconnected by the server, without explanation... async with websockets.connect( f"ws://127.0.0.1:{server.port}/review/{review.review_id}" ) as reviewer: @@ -165,18 +171,59 @@ async def test_publish_heal_and_survive_bad_requests( value = {'id': '../../../../etc/passwd', 'assoc_file': None}, section = 'test-changes' ))) + replies = [] + with pytest.raises(websockets.exceptions.ConnectionClosed) as closed: + while True: + tag, value, _ = decode_response( + await asyncio.wait_for(reviewer.recv(), 10) + ) + replies.append(tag) + assert 'root:' not in str(value) + assert set(replies) == {'review.connection-state'}, replies + assert closed.value.rcvd is not None + assert closed.value.rcvd.code == 3001 + + # A reviewer on a /batch socket takes the whole socket down with it. + async with websockets.connect( + f"ws://127.0.0.1:{server.port}/batch") as batch: + await batch.send(encode_message(message( + 'batch.subscribe', + value = {'type': 'review', 'id': str(review.review_id)} + ))) + tag, value, _ = decode_response( + await asyncio.wait_for(batch.recv(), 10) + ) + assert tag == 'batch.subscribe', (tag, value) + subscription_id = uuid.UUID(value['subscription_id']) + await batch.send(encode_message(batch_event_message( + subscription_id, + message( + 'test.src.show-src', + value = {'id': '../../../../etc/passwd', 'assoc_file': None}, + section = 'test-changes' + ) + ))) + with pytest.raises(websockets.exceptions.ConnectionClosed) as closed: + while True: + frame = await asyncio.wait_for(batch.recv(), 10) + assert 'root:' not in str(frame) + assert closed.value.rcvd is not None + assert closed.value.rcvd.code == 3001 + + # ...while the section keeps serving everyone else. + async with websockets.connect( + f"ws://127.0.0.1:{server.port}/review/{review.review_id}" + ) as reviewer: await reviewer.send(encode_message(message( 'test.src.list-statuses', section = 'test-changes' ))) - replies = [] - while 'test.src.list-statuses' not in replies: - tag, value, _ = decode_response( + tag = None + while tag != 'test.src.list-statuses': + tag, _, _ = decode_response( await asyncio.wait_for(reviewer.recv(), 10) ) - replies.append(tag) - assert tag != 'test.missing-src', replies - assert 'root:' not in str(value) + assert tag not in ('test.missing-src', 'illegal-access', 'invalid') await _wait_for_sources(server.port, review.review_id, ['test-changes']) # After the server restarts (losing all state), the section is @@ -190,3 +237,17 @@ async def test_publish_heal_and_survive_bad_requests( finally: task.cancel() await asyncio.gather(task, return_exceptions = True) + + +async def test_reviewer_with_outdated_protocol_is_told(server: Server) -> None: + async with websockets.connect( + f"ws://127.0.0.1:{server.port}/review/{uuid.uuid4()}") as reviewer: + await reviewer.send(json.dumps({ + 't': 'test.src.list-statuses', + 'r': PROTOCOL_REVISION - 1, + 's': 'test-changes', + })) + tags = [] + while 'outdated-proto' not in tags: + frame = json.loads(await asyncio.wait_for(reviewer.recv(), 10)) + tags.append(frame['t']) diff --git a/dev_tools/services/acknowledg-forge/tests/test_publisher_worker.py b/dev_tools/services/acknowledg-forge/tests/test_publisher_worker.py new file mode 100644 index 0000000000..3c620136e0 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/test_publisher_worker.py @@ -0,0 +1,171 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''SubprocessPublisher: serving reviewers from a separate, sandboxed +process that can't see the service's secrets. +''' + +import asyncio +import shutil +import subprocess +import sys +import uuid + +from pathlib import Path +from typing import Any + +import pytest +import websockets + +from edgacknowledg import decode_response, encode_message, message +from edgacknowledg.forge.db import SectionRecord +from edgacknowledg.forge.publisher import ( + KIND_TEST_CHANGES, + ReviewIdentity, + SubprocessPublisher, +) +from edgacknowledg.forge.sandbox import Sandbox + +from test_acknowledg_integration import ( + Server, + _wait_for_sources, + pytestmark, + server, +) + +__all__ = ['pytestmark', 'server'] + +API_KEY = 'acknowledg-api-key-value' + + +def _layout(tmp_path: Path) -> tuple[Path, Path, SectionRecord]: + '''A service home with a database and clones, and one review in it.''' + home = tmp_path / 'home' + reviews = home / 'reviews' + (home / 'repos').mkdir(parents = True) + (home / 'repos' / 'clone-file').write_text('clone\n') + (home / 'state.db').write_text('database\n') + tests_home = reviews / str(uuid.uuid4()) / 'base' / 'tests' + (tests_home / 'tests' / 'suite').mkdir(parents = True) + (tests_home / 'tests' / 'suite' / 'a.sft.cpp').write_text('int a;\n') + root = tests_home / 'runs' / 'r1' + (root / 'edg_x86_64').mkdir(parents = True) + (root / 'edg_x86_64' / 'changes.elist').write_text('suite/a.sft.cpp\n') + return home, reviews, SectionRecord( + kind = KIND_TEST_CHANGES, + name = 'Test - Changes', + slug = 'test-changes', + source_key = 'k1', + content_fingerprint = 'f1', + root = root, + home = tests_home, + ) + + +async def _sandbox() -> Sandbox: + sandbox = Sandbox(enabled = shutil.which('bwrap') is not None) + await sandbox.check() + return sandbox + + +async def _show_source(port: int, review_id: uuid.UUID) -> Any: + async with websockets.connect( + f"ws://127.0.0.1:{port}/review/{review_id}") as reviewer: + await reviewer.send(encode_message(message( + 'test.src.show-src', + value = {'id': 'suite/a.sft.cpp', 'assoc_file': None}, + section = 'test-changes' + ))) + while True: + tag, value, _ = decode_response( + await asyncio.wait_for(reviewer.recv(), 10) + ) + if tag == 'test.src.show-src': + return value + + +async def test_worker_serves_and_recovers(tmp_path: Path, + server: Server) -> None: + home, reviews, section = _layout(tmp_path) + publisher = SubprocessPublisher( + api_host = '127.0.0.1', + api_port = server.port, + api_key = None, + ui_host = 'ui.example', + ui_port = 443, + sandbox = await _sandbox(), + reviews_dir = reviews, + home = home, + restart_delay = 0.1 + ) + task = asyncio.create_task(publisher.run()) + try: + await asyncio.wait_for(publisher.wait_connected(), 30) + review = ReviewIdentity(uuid.uuid4(), 'edg/compiler#1: test', '') + await publisher.sync(review, [section]) + await _wait_for_sources(server.port, review.review_id, ['test-changes']) + value = await _show_source(server.port, review.review_id) + assert 'int a;' in str(value) + + # The worker dies: its sections go with its connection, and come back + # with the restarted worker. + process = publisher._process + assert process is not None + process.kill() + await _wait_for_sources(server.port, review.review_id, []) + await asyncio.wait_for(publisher.wait_connected(), 30) + await _wait_for_sources(server.port, review.review_id, ['test-changes']) + + await publisher.remove(review.review_id) + await _wait_for_sources(server.port, review.review_id, []) + finally: + task.cancel() + await asyncio.gather(task, return_exceptions = True) + + +@pytest.mark.skipif(shutil.which('bwrap') is None, + reason = 'bubblewrap is not installed') +async def test_worker_sees_only_reviews(tmp_path: Path) -> None: + home, reviews, section = _layout(tmp_path) + credential = tmp_path / 'credentials' / 'forge-token' + credential.parent.mkdir() + credential.write_text('forge-token-value\n') + publisher = SubprocessPublisher( + api_host = '127.0.0.1', + api_port = 1, + api_key = API_KEY, + ui_host = 'ui.example', + ui_port = 443, + sandbox = await _sandbox(), + reviews_dir = reviews, + home = home + ) + command = publisher._worker_command() + # The API key goes over stdin, not the command line or environment. + assert API_KEY not in ' '.join(command) + + # The worker's sandbox, running a probe instead of the worker. + prefix = command[:command.index(sys.executable)] + test_file = section.home / 'tests' / 'suite' / 'a.sft.cpp' + probe = subprocess.run( + [ + *prefix, 'sh', '-c', + f"cat {home / 'state.db'} {home / 'repos' / 'clone-file'} " + f"{credential} 2>/dev/null; " + f"cat {test_file}; " + f"touch {section.root / 'tampered'} 2>/dev/null && echo WROTE; " + f"{sys.executable} -c 'import edgacknowledg.forge.publisher_worker' " + f"&& echo IMPORTS-OK" + ], + capture_output = True, + text = True, + timeout = 60 + ) + assert 'int a;' in probe.stdout, probe + assert 'IMPORTS-OK' in probe.stdout, probe + assert 'database' not in probe.stdout + assert 'clone' not in probe.stdout + assert 'forge-token-value' not in probe.stdout + assert 'WROTE' not in probe.stdout diff --git a/dev_tools/services/acknowledg-forge/tests/test_safety.py b/dev_tools/services/acknowledg-forge/tests/test_safety.py index 847cf7df44..ac1a73c9df 100644 --- a/dev_tools/services/acknowledg-forge/tests/test_safety.py +++ b/dev_tools/services/acknowledg-forge/tests/test_safety.py @@ -16,7 +16,7 @@ import pytest -from edgacknowledg.forge.sandbox import Sandbox +from edgacknowledg.forge.sandbox import Sandbox, SandboxUnavailableError from edgacknowledg.forge.workspace import ( UnsafeArchiveError, WorkspaceError, @@ -219,3 +219,28 @@ async def test_sandbox_provides_frozen_tools(tmp_path: Path) -> None: # ...and read-only. result = await sandbox.run(['touch', str(script)], timeout = 30) assert not result.succeeded + + +@pytest.mark.parametrize('version_output', [ + 'bubblewrap 0.7.0', + 'bubblewrap 0.6.2', + 'something else', +]) +async def test_sandbox_requires_bwrap_0_8(tmp_path: Path, + version_output: str) -> None: + fake = tmp_path / 'bwrap' + fake.write_text(f"#!/bin/sh\necho '{version_output}'\n") + fake.chmod(0o755) + with pytest.raises(SandboxUnavailableError, match = '0.8 or later'): + await Sandbox(enabled = True, bwrap = str(fake)).check() + + +@needs_bwrap +async def test_sandboxed_commands_cannot_create_namespaces() -> None: + sandbox = Sandbox(enabled = True) + await sandbox.check() + result = await sandbox.run( + ['sh', '-c', 'unshare -U true && echo UNSHARED'], + timeout = 30 + ) + assert 'UNSHARED' not in result.output, result.output diff --git a/dev_tools/services/acknowledg-forge/tests/test_symlinks.py b/dev_tools/services/acknowledg-forge/tests/test_symlinks.py new file mode 100644 index 0000000000..88e1b58861 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/test_symlinks.py @@ -0,0 +1,630 @@ +# Part of the EDG Compiler Project, under the Apache License v2.0 with LLVM +# Exceptions. +# See https://edgcpp.org/LICENSE.txt for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception + +'''Symlinks (and other non-regular files) in pull requests, CI artifacts, +and sandbox output must never let reviewers read outside the review. +''' + +import os +import shutil +import uuid + +from pathlib import Path, PurePosixPath +from typing import Any, Optional + +import pytest + +from edgacknowledg import RequestTags, encode_message, message +from edgacknowledg.client.bench import ( + BenchReviewSession, + collect_all_benchmarks, + read_program_totals, + resolve_baseline_dir, +) +from edgacknowledg.client.shared import ( + ConfinedPathComponent, + SandboxContext, +) +# (Not 'from ... import TestSourceHandler', which pytest would collect.) +import edgacknowledg.client.test as test_provider + +from edgacknowledg.client.test import ( + IllegalFileAccessError, + _test_if_any_output_exists, +) +from edgacknowledg.forge.db import ForgeStore, SectionRecord +from edgacknowledg.forge.publisher import ( + KIND_BENCH, + KIND_TEST_FAILURES, + AcknowledgPublisher, +) +from edgacknowledg.forge.sandbox import Sandbox +from edgacknowledg.forge.workspace import ( + WorkspaceError, + Workspaces, + check_plain_tree, + require_real_directory, +) + +from conftest import ( + STUB_REMATERIALIZE, + Harness, + Origin, + git, + make_config, + pull_request_info, +) +from fakes import REPOSITORY, Clock, FakeForge + +SECRET = 'forge-token-value\n' + + +@pytest.fixture +def secret(tmp_path: Path) -> Path: + '''Stands in for a credential (or the database) outside every review.''' + path = tmp_path / 'credentials' / 'forge-token' + path.parent.mkdir() + path.write_text(SECRET) + return path + + +def _push_with_link(origin: Origin, + number: int, + link: str, + target: str) -> str: + '''A pull request replacing (or adding) ``link`` with a symlink.''' + git(origin.path, 'checkout', '--quiet', '-B', f"pr-{number}", + origin.main_sha) + path = origin.path / link + path.parent.mkdir(parents = True, exist_ok = True) + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink(missing_ok = True) + os.symlink(target, path) + git(origin.path, 'add', '--all') + git(origin.path, 'commit', '--quiet', '-m', f"pull request {number}") + git(origin.path, 'checkout', '--quiet', '--detach') + sha = git(origin.path, 'rev-parse', 'HEAD') + origin.set_ref(f"refs/pull/{number}/head", sha) + return sha + + +# Workspaces + + +def test_check_plain_tree(tmp_path: Path, secret: Path) -> None: + root = tmp_path / 'tree' + (root / 'dir').mkdir(parents = True) + (root / 'dir' / 'file').write_text('x') + check_plain_tree(root) + + os.symlink(secret, root / 'dir' / 'link') + with pytest.raises(WorkspaceError): + check_plain_tree(root) + (root / 'dir' / 'link').unlink() + + os.mkfifo(root / 'fifo') + with pytest.raises(WorkspaceError): + check_plain_tree(root) + (root / 'fifo').unlink() + + os.link(root / 'dir' / 'file', root / 'hard') + check_plain_tree(root) + with pytest.raises(WorkspaceError): + check_plain_tree(root, allow_hard_links = False) + + +def test_require_real_directory(tmp_path: Path) -> None: + (tmp_path / 'a' / 'b').mkdir(parents = True) + os.symlink(tmp_path / 'a', tmp_path / 'link') + assert require_real_directory(tmp_path, PurePosixPath('a', 'b')) == ( + tmp_path / 'a' / 'b' + ) + for relative in ('link/b', 'a/missing', 'a/../a'): + with pytest.raises(WorkspaceError): + require_real_directory(tmp_path, PurePosixPath(relative)) + assert not (tmp_path / 'a' / 'missing').exists() + + +async def test_checkout_neutralizes_committed_symlinks(tmp_path: Path, + origin: Origin, + forge: FakeForge, + secret: Path) -> None: + link = 'tests/tests/suite/.a.rto/default.1.1.txt' + sha = _push_with_link(origin, 1, link, str(secret)) + workspaces = Workspaces(tmp_path / 'home', forge) + info = pull_request_info(1, sha, origin.main_sha) + head, _ = await workspaces.fetch_pull_request(info) + worktree = await workspaces.checkout(info, 'r1', 'head', head, ['/tests/']) + + # A plain file holding the link's target, not the secret. + checked_out = worktree / link + assert not checked_out.is_symlink() + assert checked_out.read_text() == str(secret) + config = git( + workspaces.clone_dir(REPOSITORY), 'config', '--get', 'core.symlinks' + ) + assert config == 'false' + + # A worktree that somehow holds a link anyway is refused. + os.symlink(secret.parent, worktree / 'tests' / 'planted') + with pytest.raises(WorkspaceError): + await workspaces.checkout(info, 'r1', 'head', head, ['/tests/']) + + +# Processing + + +async def test_links_in_sandbox_output_are_not_served(tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock, + secret: Path) -> None: + config = make_config(tmp_path / 'home') + # A rematerializer that (as a patch applying a symlink diff could) leaves + # a link to a service file in its output. + (config.tools_dir / 'bin' / 'edg-rematerialize-test-run').write_text( + STUB_REMATERIALIZE + + f"os.symlink({str(secret)!r}, config_dir / 'changes.elog')\n" + ) + harness = Harness(store, forge, config, clock) + await harness.start() + head = origin.push_pull_request(1, { + 'tests/tests/suite/.a.rto/default.1.1.txt': 'new output\n', + }) + forge.set_pull_request(pull_request_info(1, head, origin.main_sha)) + await harness.step() + + record = await store.find_pull_request('fake', REPOSITORY, 1) + assert record is not None + assert harness.publisher.served_names(record.review_id) == [] + # Nothing of it is left behind to be served later. + runs = tmp_path / 'home' / 'reviews' / str(record.review_id) / 'base' + assert list((runs / 'tests' / 'runs').iterdir()) == [] + + +async def test_linked_bench_baselines_are_not_served(tmp_path: Path, + origin: Origin, + forge: FakeForge, + store: ForgeStore, + clock: Clock) -> None: + from conftest import ci_run + from edgacknowledg.forge import ArtifactInfo, CIConclusion + import zipfile + + config = make_config(tmp_path / 'home') + harness = Harness(store, forge, config, clock) + await harness.start() + # The baselines directory is a link (to another review, say). + head = _push_with_link( + origin, 1, 'benchmarks/baselines/project/release-c', '../../../..' + ) + forge.set_pull_request(pull_request_info(1, head, origin.main_sha)) + artifact = tmp_path / 'bench.zip' + with zipfile.ZipFile(artifact, 'w') as archive: + archive.writestr('c/x.bnch.cgout', 'summary: 1100\n') + forge.artifact_files['b1'] = artifact + forge.runs[head] = ci_run( + head, + conclusion = CIConclusion.SUCCESS, + artifacts = (ArtifactInfo('bench-results', 'b1', 100, None, False),) + ) + await harness.step() + record = await store.find_pull_request('fake', REPOSITORY, 1) + assert record is not None + assert harness.publisher.served_names(record.review_id) == [] + + +# Providers + + +class FakeChannel: + '''Delivers one request and collects the replies.''' + + def __init__(self, request: Any) -> None: + self._request = encode_message(request) + self.sent: list[Any] = [] + + async def recv(self) -> str: + return self._request + + async def send(self, reply: Any) -> None: + self.sent.append(reply) + + +def _test_tree(tmp_path: Path, secret: Path) -> tuple[Path, Path]: + home = tmp_path / 'review' / 'tests' + suite = home / 'tests' / 'suite' + suite.mkdir(parents = True) + (suite / 'a.sft.cpp').write_text('int a;\n') + os.symlink('a.sft.cpp', suite / 'inside.sft.cpp') + os.symlink(secret, suite / 'escape.sft.cpp') + root = home / 'runs' / 'r1' + (root / 'edg_x86_64').mkdir(parents = True) + (root / 'edg_x86_64' / 'changes.elist').write_text('suite/a.sft.cpp\n') + return home, root + + +async def _show_source(handler: test_provider.TestSourceHandler, + test_id: str) -> Optional[str]: + channel = FakeChannel(message( + RequestTags.TEST_SHOW_SOURCE, + value = {'id': test_id, 'assoc_file': None, 'reviewer_id': 'r'} + )) + await handler.handle_request(channel) # type: ignore[arg-type] + [reply] = channel.sent + content: Optional[str] = reply['v']['client_response'].get('content') + return content + + +async def test_show_source_symlinks(tmp_path: Path, secret: Path) -> None: + home, root = _test_tree(tmp_path, secret) + trusted, untrusted = ( + test_provider.TestSourceHandler( + 'Test', + 'test', + context.subpath(home), + context.subpath(root) + ) + for context in (SandboxContext(home), + SandboxContext(home, allow_symlinks = False)) + ) + + assert await _show_source(trusted, 'suite/a.sft.cpp') == 'int a;\n' + assert await _show_source(untrusted, 'suite/a.sft.cpp') == 'int a;\n' + # A local review may use links within its tree... + assert await _show_source(trusted, 'suite/inside.sft.cpp') == 'int a;\n' + # ...but no review may read outside it, and an untrusted one may not + # follow links at all. + for handler, test_id in ((trusted, 'suite/escape.sft.cpp'), + (untrusted, 'suite/escape.sft.cpp'), + (untrusted, 'suite/inside.sft.cpp')): + with pytest.raises(IllegalFileAccessError) as refused: + await _show_source(handler, test_id) + # Naming the reviewer, for the server to disconnect. + assert refused.value.reviewer_id == 'r' + + +async def test_illegal_access_is_reported(tmp_path: Path, + secret: Path) -> None: + from edgacknowledg.client import ( + _reply_to_failed_request, + _report_illegal_access, + ) + + home, root = _test_tree(tmp_path, secret) + context = SandboxContext(home, allow_symlinks = False) + handler = test_provider.TestSourceHandler( + 'Test', + 'test', + context.subpath(home), + context.subpath(root) + ) + + def channel() -> FakeChannel: + return FakeChannel(message(RequestTags.INVALID_REQUEST)) + + # The reviewer is reported, for the server to disconnect... + reported = channel() + error = IllegalFileAccessError('outside') + error.reviewer_id = 'reviewer-1' + await _report_illegal_access(reported, handler, error) # type: ignore + assert reported.sent == [ + message(RequestTags.ILLEGAL_ACCESS, value = {'reviewer_id': 'reviewer-1'}) + ] + + # ...unless it can't be traced to one, like any other failure. + for send in ( + lambda c: _report_illegal_access( # type: ignore + c, handler, IllegalFileAccessError('outside')), + lambda c: _reply_to_failed_request(c, handler)): # type: ignore + invalid = channel() + try: + raise ValueError('broken') + except ValueError: + await send(invalid) + assert invalid.sent == [message(RequestTags.INVALID_REQUEST)] + + +def test_confined_path(tmp_path: Path, secret: Path) -> None: + root = tmp_path / 'tree' + (root / 'dir').mkdir(parents = True) + (root / 'dir' / 'file').write_text('x') + os.symlink('file', root / 'dir' / 'inside') + os.symlink(secret, root / 'dir' / 'escape') + os.symlink(root / 'dir', root / 'linked-dir') + os.mkfifo(root / 'fifo') + trusted = SandboxContext(root).subpath(root) + untrusted = SandboxContext(root, allow_symlinks = False).subpath(root) + + for tree in (trusted, untrusted): + # Like pathlib.Path... + file = tree / 'dir' / 'file' + assert file == tree.joinpath('dir', 'file') + assert (file.name, file.parent, file.path) == ( + 'file', tree / 'dir', root / 'dir' / 'file' + ) + assert str(file.relative) == 'dir/file' + assert file.read_text() == 'x' and file.is_file() and not file.is_dir() + assert tree.subpath(root / 'dir' / 'file') == file + # Missing files are valid; reading them fails as usual. + missing = tree / 'dir' / 'missing' + assert not missing.exists() + with pytest.raises(FileNotFoundError): + missing.read_text() + # ...but nothing can point outside the tree, however it's spelled. + for escape in (lambda: tree / '..' / 'credentials', + lambda: tree / str(secret), + lambda: tree.parent, + lambda: tree.subpath(secret), + lambda: tree / 'dir' / 'escape', + lambda: (tree / 'dir').with_name('..')): + with pytest.raises(IllegalFileAccessError): + escape() + + # Links staying inside are fine in a trusted tree only. + assert (trusted / 'linked-dir' / 'inside').read_text() == 'x' + for components in (('dir', 'inside'), ('linked-dir', 'file'), ('fifo',)): + with pytest.raises(IllegalFileAccessError): + untrusted.joinpath(*components) + + # Walking an untrusted tree never yields anything but directories and + # regular files. + walked = { + str((directory / name).relative) + for directory, dirs, files in untrusted.walk() + for name in [*dirs, *files] + } + assert walked == {'dir', 'dir/file'} + + # Even a link planted after validation isn't followed. + late = untrusted / 'dir' / 'late' + os.symlink('file', late.path) + # (It exists, as an entry, but is no file to read.) + assert late.exists() and not late.is_file() + with pytest.raises(IllegalFileAccessError): + late.read_text() + + +def test_confined_path_component(tmp_path: Path) -> None: + (tmp_path / 'run' / 'c').mkdir(parents = True) + (tmp_path / 'run' / 'c' / 'x.cgout').write_text('run\n') + (tmp_path / 'base' / 'c').mkdir(parents = True) + (tmp_path / 'base' / 'c' / 'x.cgout').write_text('base\n') + context = SandboxContext(tmp_path) + run = context.subpath(tmp_path / 'run') + base = context.subpath(tmp_path / 'base') + + component = (run / 'c' / 'x.cgout').relative_to(run) + assert str(component) == 'c/x.cgout' and component.name == 'x.cgout' + assert component == (base / 'c' / 'x.cgout').relative_to(base) + # It carries across trees... + assert (base / component).read_text() == 'base\n' + assert base.joinpath('.', component) == base / 'c' / 'x.cgout' + # ...but is no path to anything else. + with pytest.raises(TypeError): + Path('/') / component # type: ignore[operator] + with pytest.raises(TypeError): + open(component) # type: ignore[call-overload] + with pytest.raises(TypeError): + run / Path('c') # type: ignore[operator] + for invalid in (['..'], ['a/b'], ['']): + with pytest.raises(ValueError): + ConfinedPathComponent(invalid) + with pytest.raises(ValueError): + run.relative_to(base) + with pytest.raises(ValueError): + run.relative_to(SandboxContext(tmp_path).subpath(tmp_path)) + + +def test_recordings_are_validated(tmp_path: Path, secret: Path) -> None: + home, _ = _test_tree(tmp_path, secret) + recordings = home / 'tests' / 'suite' / '.a.rto' + recordings.mkdir() + env = {'EDG_CONFIG': 'edg_x86_64', 'RUN_TEST_DEFAULT_CONFIG': 'edg_x86_64'} + trusted = SandboxContext(home).subpath(recordings) + untrusted = SandboxContext(home, allow_symlinks = False).subpath(recordings) + + # What a pull request recording a link to a credential amounts to: before + # this was checked, the 'current diff' showed the credential. + os.symlink(secret, recordings / 'default.1.1.txt') + for tree in (trusted, untrusted): + with pytest.raises(IllegalFileAccessError): + _test_if_any_output_exists(tree, env, 1, 1) + + (recordings / 'default.1.1.txt').unlink() + (recordings / 'real.txt').write_text('output\n') + os.symlink('real.txt', recordings / 'default.1.1.txt') + assert _test_if_any_output_exists(trusted, env, 1, 1) == ( + trusted / 'default.1.1.txt' + ) + with pytest.raises(IllegalFileAccessError): + _test_if_any_output_exists(untrusted, env, 1, 1) + + +def test_bench_reads_are_confined(tmp_path: Path) -> None: + bench_home = tmp_path / 'benchmarks' + baseline = bench_home / 'baselines' / 'project' / 'release-c' + run = bench_home / 'runs' / 'ci-1' / 'c' + baseline.mkdir(parents = True) + run.mkdir(parents = True) + inside = baseline / 'real.cgout' + inside.write_text('events: Ir\nsummary: 1234\n') + os.symlink(inside, baseline / 'x.bnch.cgout') + (run / 'x.bnch.cgout').write_text('events: Ir\nsummary: 1100\n') + (run / 'y.bnch.cgout').write_text('events: Ir\nsummary: 1100\n') + outside = tmp_path / 'outside.cgout' + outside.write_text('events: Ir\nsummary: 999\n') + os.symlink(outside, baseline / 'y.bnch.cgout') + + # A link staying in the baselines works locally; one leaving them never + # does, and a pull request's baselines may not use links at all. + trusted = SandboxContext(baseline).subpath(baseline) + assert read_program_totals(trusted / 'x.bnch.cgout') == 1234 + with pytest.raises(IllegalFileAccessError): + read_program_totals(trusted / 'y.bnch.cgout') + local = collect_all_benchmarks(SandboxContext(run).subpath(run), trusted) + assert [str(bm.rel_benchmark_path) for bm in local] == ['x.bnch.cgout'] + assert collect_all_benchmarks( + SandboxContext(run, allow_symlinks = False).subpath(run), + SandboxContext(baseline, allow_symlinks = False).subpath(baseline) + ) == [] + + +def test_sandbox_context_commands(tmp_path: Path) -> None: + (tmp_path / 'home' / 'file').parent.mkdir() + context = SandboxContext( + tmp_path / 'home', + allow_symlinks = False, + command_prefix = ['bwrap', '--'], + command_timeout = 5 + ) + file = context.subpath(tmp_path / 'home' / 'file') + assert context.command('tool', '--flag', file) == [ + 'bwrap', '--', 'tool', '--flag', str(tmp_path / 'home' / 'file') + ] + # Only this context's paths may be passed. + other = SandboxContext(tmp_path / 'home', allow_symlinks = False) + with pytest.raises(ValueError): + context.command('tool', other.subpath(tmp_path / 'home' / 'file')) + local = SandboxContext(tmp_path) + assert local.allow_symlinks and local.command('tool') == ['tool'] + + +def test_permitted_roots(tmp_path: Path, secret: Path) -> None: + home = tmp_path / 'home' + (home / 'runs' / 'r1').mkdir(parents = True) + (home / 'data').write_text('home data\n') + run = tmp_path / 'downloads' / 'run' + run.mkdir(parents = True) + (run / 'output').write_text('output\n') + os.symlink(home / 'data', run / 'into-home') + # A run outside the home must be one of the roots... + with pytest.raises(IllegalFileAccessError): + SandboxContext(home).subpath(run / 'output') + permitted = SandboxContext(home, run) + assert permitted.roots == (home, run) + assert permitted.subpath(run / 'output').read_text() == 'output\n' + # ...one inside it (or a duplicate) is folded into it, in any order. + assert SandboxContext(home / 'runs' / 'r1', home, home).roots == (home,) + assert SandboxContext(run, home / 'runs', home).roots == (run, home) + with pytest.raises(ValueError): + SandboxContext() + + # Each path is confined to its own root: a link from the run into the + # home (or anywhere else) is refused, as is '..' out of the run. + output = permitted.subpath(run / 'output') + assert output.tree_root == permitted.subpath(run) + for escape in (lambda: permitted.subpath(run / 'into-home'), + lambda: output.parent.parent, + lambda: output.tree_root / '..' / '..' / 'home' / 'data'): + with pytest.raises(IllegalFileAccessError): + escape() + assert permitted.subpath(home / 'data').read_text() == 'home data\n' + + +def test_publisher_requires_real_section_directories(tmp_path: Path, + secret: Path) -> None: + reviews = tmp_path / 'home' / 'reviews' + worktree = reviews / str(uuid.uuid4()) / 'head' + (worktree / 'benchmarks' / 'runs' / 'ci-1' / 'c').mkdir(parents = True) + publisher = AcknowledgPublisher( + api_host = '127.0.0.1', + api_port = 1, + api_key = None, + ui_host = 'ui.example', + ui_port = 443, + sandbox = Sandbox(enabled = False), + reviews_dir = reviews + ) + + # A test home that is a link (as if a pull request committed 'tests'). + os.symlink(secret.parent, worktree / 'tests') + with pytest.raises(WorkspaceError): + publisher._build_handler(SectionRecord( + kind = KIND_TEST_FAILURES, + name = 'Test - Failures', + slug = 'test-failures', + source_key = 'k', + content_fingerprint = 'f', + root = worktree / 'tests' / 'runs' / 'ci-1', + home = worktree / 'tests', + )) + + # Baselines outside the bench home. + (tmp_path / 'elsewhere').mkdir() + with pytest.raises(WorkspaceError): + publisher._build_handler(SectionRecord( + kind = KIND_BENCH, + name = 'Bench - C', + slug = 'bench-c', + source_key = 'k', + content_fingerprint = 'f', + root = worktree / 'benchmarks' / 'runs' / 'ci-1' / 'c', + home = worktree / 'benchmarks', + details = { + 'baseline_dir': str(tmp_path / 'elsewhere'), + 'tag': 'release-c', + }, + )) + + +def test_bench_session_trees(tmp_path: Path) -> None: + bench_home = tmp_path / 'benchmarks' + baseline = bench_home / 'baselines' / 'project' / 'release-c' + baseline.mkdir(parents = True) + (baseline / 'x.bnch.cgout').write_text('events: Ir\nsummary: 1000\n') + # A local run kept elsewhere (edg-bench-review --root). + elsewhere = tmp_path / 'downloads' / 'run' + elsewhere.mkdir(parents = True) + (elsewhere / 'x.bnch.cgout').write_text('events: Ir\nsummary: 1100\n') + + # Not without permission (as edg-bench-review --root gives)... + for context in (SandboxContext(bench_home), + SandboxContext(bench_home, allow_symlinks = False)): + with pytest.raises(IllegalFileAccessError): + context.subpath(elsewhere) + # ...but with it. + context = SandboxContext(bench_home, elsewhere) + local = BenchReviewSession( + run_dir = context.subpath(elsewhere), + baseline_dir = resolve_baseline_dir( + context.subpath(bench_home), + 'release-c' + ), + tag = 'release-c' + ) + assert list(local.metadata_by_id) == ['x.bnch.cgout'] + assert local.baseline_dir == context.subpath(baseline) + # The run and baselines must share their context. + with pytest.raises(ValueError): + BenchReviewSession( + run_dir = context.subpath(elsewhere), + baseline_dir = SandboxContext(bench_home).subpath(baseline), + tag = 'release-c' + ) + # A tag can't lead out of the bench home. + with pytest.raises(IllegalFileAccessError): + resolve_baseline_dir(context.subpath(bench_home), '@../../../downloads') + + +async def test_test_run_outside_home(tmp_path: Path, secret: Path) -> None: + home, _ = _test_tree(tmp_path, secret) + # A run kept elsewhere (edgy-review --root). + run = tmp_path / 'downloads' / 'run' + (run / 'edg_x86_64').mkdir(parents = True) + (run / 'edg_x86_64' / 'changes.elist').write_text('suite/a.sft.cpp\n') + with pytest.raises(IllegalFileAccessError): + SandboxContext(home).subpath(run) + context = SandboxContext(home, run) + handler = test_provider.TestSourceHandler( + 'Test', + 'test', + context.subpath(home), + context.subpath(run) + ) + assert await _show_source(handler, 'suite/a.sft.cpp') == 'int a;\n' diff --git a/dev_tools/services/acknowledg/PROTOCOL.md b/dev_tools/services/acknowledg/PROTOCOL.md index b00bf488bd..a13a769587 100644 --- a/dev_tools/services/acknowledg/PROTOCOL.md +++ b/dev_tools/services/acknowledg/PROTOCOL.md @@ -3,7 +3,7 @@ This document describes the WebSocket protocol used by the AcknowlEDG server, the Vue client, and the `edgy-review` / `edg-bench-review` / `edg-acknowledg-cli` providers. -The current protocol revision is **18**. +The current protocol revision is **19**. All frames are JSON text. Implementations live in `edgacknowledg` (`dev_tools/pylibs/edgacknowledg`) and must stay in sync with the JavaScript @@ -163,6 +163,7 @@ Closing `/batch` cancels every subscription for that connection. | `invalid` | unused / `null` | Malformed request, bad slug, failed handshake, or unexpected tag | | `outdated-proto` | `null` | Peer `r` does not match | | `role-occupied` | `null` | Another provider already holds this UUID + slug | +| `illegal-access` | see [Illegal access](#illegal-access) | A reviewer requested a file outside the section | | `test.missing-src` | `null` | Reviewer request needs a test source that is not connected | | `test.missing-edit` | `null` | Reviewer request needs a test editor that is not connected | | `bench.missing-src` | `null` | Reviewer request needs a bench source that is not connected | @@ -294,6 +295,22 @@ provider slug. `configs` is present only for test recording updates. +### Illegal access + +When a reviewer's request names a file outside the section's tree (or, for +content that isn't the provider user's own, one reached through a symlink), +the provider doesn't answer the request. It reports the reviewer instead: + +```json +{ "t": "illegal-access", "r": 19, "v": { "reviewer_id": "" } } +``` + +The server then closes that reviewer's socket with code **3001**, without +any message: the reviewer UI never sends such requests, and doesn't reconnect +by itself. For a reviewer in a `/batch` `review` subscription that is the +whole `/batch` socket, ending every subscription on it. A provider can only +report reviewers of its own review. + ### Source `client_response` shapes (reviewer `v`) Display payloads that carry file text use `content` (string or `null` if @@ -397,6 +414,7 @@ match the representative. | `invalid` | server → any | | `outdated-proto` | server → any | | `role-occupied` | server → source/edit (or nested in `batch.event`) | +| `illegal-access` | source/edit → server | | `meta.section.name` | provider → server (optional tab label) | | `meta.review.name` | provider → server (optional review title) | | `meta.review.timestamp` | provider → server (optional created_at) | diff --git a/dev_tools/services/acknowledg/client/src/sockets.js b/dev_tools/services/acknowledg/client/src/sockets.js index 1a2383e94f..cd57a09e9a 100644 --- a/dev_tools/services/acknowledg/client/src/sockets.js +++ b/dev_tools/services/acknowledg/client/src/sockets.js @@ -5,7 +5,7 @@ See https://edgcpp.org/LICENSE.txt for license information. SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception */ -const PROTOCOL_REVISION = 18 +const PROTOCOL_REVISION = 19 const getSocketHost = () => { if (process.env.NODE_ENV === "development") { diff --git a/dev_tools/services/acknowledg/server/acknowledg_server/__init__.py b/dev_tools/services/acknowledg/server/acknowledg_server/__init__.py index d42d2632f0..93a1beba4f 100644 --- a/dev_tools/services/acknowledg/server/acknowledg_server/__init__.py +++ b/dev_tools/services/acknowledg/server/acknowledg_server/__init__.py @@ -53,6 +53,8 @@ # IANA WebSocket Close Code Number Registry: 3000 Unauthorized WS_CLOSE_UNAUTHORIZED = 3000 +# A provider reported that the reviewer requested a file it may not read. +WS_CLOSE_ILLEGAL_ACCESS = 3001 F = TypeVar('F', bound = Callable[..., Any]) @@ -143,6 +145,15 @@ class NoBenchSourceSocket(Exception): class NoTestEditSocket(Exception): pass +class ReviewerExpelled(Exception): + '''Ends a reviewer session that a provider reported for illegal access.''' + pass + +# Queued to a reviewer's session to end it (``ReviewerExpelled``); never +# sent. Such a reviewer gets no explanation: the request couldn't have come +# from the reviewer UI. +_EXPEL_REVIEWER: ProtocolMessage = {} + class NoBenchEditSocket(Exception): pass @@ -292,6 +303,12 @@ async def publish_to_reviewer(self, reviewer_id: uuid.UUID, async def broadcast_to_reviewers(self, msg: ProtocolMessage) -> None: await self._reviewer_brokers.publish_all(msg) + async def expel_reviewer(self, reviewer_id: uuid.UUID) -> None: + '''End the session of a reviewer that a provider reported for illegal + access (``ReviewerExpelled``). + ''' + await self.publish_to_reviewer(reviewer_id, _EXPEL_REVIEWER) + def _subscribe_section_slot( self, slots: Dict[str, SectionSlot], @@ -644,6 +661,16 @@ async def _receive_provider_replies( ): continue + if request_tag == RequestTags.ILLEGAL_ACCESS: + try: + offender = uuid.UUID(str(request_value['reviewer_id'])) + except (KeyError, TypeError, ValueError): + await send(message(RequestTags.INVALID_REQUEST)) + continue + # Reviewers are per review, so a provider can only name its own. + await run_descr.expel_reviewer(offender) + continue + if (request_tag not in allowed_tags or request_value is None or 'reviewer_id' not in request_value): @@ -793,8 +820,14 @@ async def _run_reviewer_session( run_descr: ReviewDescriptor, *, send: SendFn, - recv: RecvFn) -> None: - '''Run a browser/reviewer session for one review.''' + recv: RecvFn) -> bool: + '''Run a browser/reviewer session for one review. + + Return whether it ended because a provider reported the reviewer for + illegal access; the caller then drops the connection, without telling the + reviewer why. + ''' + expelled = False try: reviewer_id = uuid.uuid4() @@ -804,6 +837,8 @@ async def push_outbound() -> None: value = run_descr.connection_state() )) async for msg in run_descr.subscribe_as_reviewer(reviewer_id): + if msg is _EXPEL_REVIEWER: + raise ReviewerExpelled() await send(msg) async def pull_inbound() -> None: @@ -837,10 +872,13 @@ async def pull_inbound() -> None: async with asyncio.TaskGroup() as tg: tg.create_task(push_outbound()) tg.create_task(pull_inbound()) - except IncompatibleProtocolVersion: + except* IncompatibleProtocolVersion: await send(outdated_protocol_message()) + except* ReviewerExpelled: + expelled = True finally: await remove_run_if_dead(run_descr.run_id) + return expelled def _websocket_send() -> SendFn: async def send(msg: ProtocolMessage) -> None: @@ -864,11 +902,11 @@ async def reviews_handler() -> None: @app.websocket('/review/') async def review_handler(run_id: uuid.UUID) -> None: run_descr = _get_or_create_run(run_id) - await _run_reviewer_session( - run_descr, - send = _websocket_send(), - recv = _websocket_recv() - ) + if await _run_reviewer_session( + run_descr, + send = _websocket_send(), + recv = _websocket_recv()): + await websocket.close(WS_CLOSE_ILLEGAL_ACCESS) @app.websocket('/edit//test/') @require_api_key @@ -977,6 +1015,27 @@ def _provider_subscribe_for_type( @require_api_key async def batch_handler() -> None: subscriptions: Dict[uuid.UUID, _BatchSubscription] = {} + # Set when a reviewer session on this socket is expelled for illegal + # access: the whole socket is closed, every other subscription with it. + expelled = asyncio.Event() + + async def receive() -> Any: + '''The next frame; raise ``ReviewerExpelled`` once a reviewer session + on this socket was expelled. + ''' + frame = asyncio.ensure_future(websocket.receive()) + expulsion = asyncio.ensure_future(expelled.wait()) + try: + await asyncio.wait( + {frame, expulsion}, + return_when = asyncio.FIRST_COMPLETED + ) + finally: + for task in (frame, expulsion): + task.cancel() + if expelled.is_set(): + raise ReviewerExpelled() + return frame.result() async def cancel_subscription(subscription_id: uuid.UUID) -> None: state = subscriptions.pop(subscription_id, None) @@ -1027,7 +1086,8 @@ async def run_session() -> None: try: if sub_type == 'review': run_descr = _get_or_create_run(run_id) - await _run_reviewer_session(run_descr, send = send, recv = recv) + if await _run_reviewer_session(run_descr, send = send, recv = recv): + expelled.set() return slug = value.get('slug') @@ -1071,11 +1131,14 @@ async def run_session() -> None: while True: try: request_tag, request_value, _section = decode_response( - await websocket.receive() + await receive() ) except IncompatibleProtocolVersion: await websocket.send(encode_message(outdated_protocol_message())) return + except ReviewerExpelled: + await websocket.close(WS_CLOSE_ILLEGAL_ACCESS) + return if request_tag == RequestTags.BATCH_SUBSCRIBE: await start_subscription(request_value)