From 284e98e14b38f6276a555b545e7f278ff02fd8df Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Thu, 15 Oct 2026 16:13:43 +0000 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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) From a50e9a2e9d3deeb2bd694398d64d2e4de9964942 Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Mon, 19 Oct 2026 15:16:31 +0000 Subject: [PATCH 5/9] Decouple SandboxContext and ConfinedPath [] --- dev_tools/bin/edg-acknowledg-cli | 2 + dev_tools/bin/edg-bench-review | 1 + dev_tools/bin/edgy-review | 1 + .../edgacknowledg/client/bench/__init__.py | 9 ++- .../pylibs/edgacknowledg/client/shared.py | 65 +++++++++---------- .../edgacknowledg/client/test/__init__.py | 16 +++-- .../pylibs/edgacknowledg/forge/publisher.py | 2 + .../acknowledg-forge/tests/test_symlinks.py | 17 ++--- 8 files changed, 52 insertions(+), 61 deletions(-) diff --git a/dev_tools/bin/edg-acknowledg-cli b/dev_tools/bin/edg-acknowledg-cli index 1c3276f2c1..cf89d003ac 100755 --- a/dev_tools/bin/edg-acknowledg-cli +++ b/dev_tools/bin/edg-acknowledg-cli @@ -100,6 +100,7 @@ def _make_bench_session(parameters: Dict[str, Any], # may read. context = SandboxContext(bench_home, root) session = BenchReviewSession( + context = context, run_dir = context.subpath(root), baseline_dir = resolve_baseline_dir(context.subpath(bench_home), tag), tag = tag, @@ -140,6 +141,7 @@ def _make_handler(section: Dict[str, Any], return TestSourceHandler( section_name, slug, + context, context.subpath(test_home), context.subpath(root) ) diff --git a/dev_tools/bin/edg-bench-review b/dev_tools/bin/edg-bench-review index 7a49b3823c..68e4c3a83f 100755 --- a/dev_tools/bin/edg-bench-review +++ b/dev_tools/bin/edg-bench-review @@ -176,6 +176,7 @@ def main() -> None: sys.exit(1) session = BenchReviewSession( + context = context, run_dir = context.subpath(run_dir), baseline_dir = baseline_dir, tag = args.tag, diff --git a/dev_tools/bin/edgy-review b/dev_tools/bin/edgy-review index f0583a9709..5cc261533a 100755 --- a/dev_tools/bin/edgy-review +++ b/dev_tools/bin/edgy-review @@ -170,6 +170,7 @@ def main() -> None: await review.add_section(TestSourceHandler( args.section_name, slug, + context, context.subpath(test_home), context.subpath(run_dir) )) diff --git a/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py b/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py index c0fd1ca952..ab9065a75e 100644 --- a/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py @@ -225,17 +225,16 @@ class BenchReviewSession: '''Shared source/editor state for one benchmark review section.''' def __init__(self, *, + context: SandboxContext, run_dir: ConfinedPath, baseline_dir: ConfinedPath, tag: str, threshold_slow: float = DEFAULT_THRESHOLD_SLOW, 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. + '''Every read and the baseline update go through ``run_dir`` and + ``baseline_dir``; cg_annotate runs in ``context``. ''' - 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.context = context self.run_dir = run_dir self.baseline_dir = baseline_dir self.tag = tag diff --git a/dev_tools/pylibs/edgacknowledg/client/shared.py b/dev_tools/pylibs/edgacknowledg/client/shared.py index 0eb31ecdde..fd22895822 100644 --- a/dev_tools/pylibs/edgacknowledg/client/shared.py +++ b/dev_tools/pylibs/edgacknowledg/client/shared.py @@ -194,8 +194,9 @@ def __init__(self, *args: Any) -> None: 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. + Providers make ``ConfinedPath``s with one (``subpath``), reach every file + through them, and run every command through the context itself + (``command``), which they are handed alongside the paths. - ``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 @@ -247,21 +248,20 @@ def subpath(self, path: Path) -> 'ConfinedPath': ''' for root in self._roots: if _is_under(path, root): - return ConfinedPath(self, root)._derive(path) + return ConfinedPath(root, path, + allow_symlinks = self._allow_symlinks) 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. + passed as ``ConfinedPath`` (so they were validated), never as plain + paths; the command only sees those under this context's roots. ''' 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) @@ -332,7 +332,7 @@ class ConfinedPath: 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 + - if symlinks are allowed, 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 @@ -342,30 +342,31 @@ class ConfinedPath: 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. + Its tree and policy are given when it is made (usually by + ``SandboxContext.subpath``, from one of the context's roots and its + policy); paths derived from it share 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. + def __init__(self, + root: Path, + path: Path, + *, + allow_symlinks: bool) -> None: + '''``path``, confined to the tree at ``root``; usually made by + ``SandboxContext.subpath``. ''' - 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 + self._path = path + self._untrusted = not allow_symlinks + self._validate() def _derive(self, path: Path) -> 'ConfinedPath': - derived = ConfinedPath(self._context, self._root) - derived._path = path - derived._validate() - return derived + return ConfinedPath(self._root, path, + allow_symlinks = not self._untrusted) def _illegal(self, path: Optional[Path] = None) -> IllegalFileAccessError: return IllegalFileAccessError( @@ -424,11 +425,7 @@ def joinpath(self, 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") + '''This path relative to ``other``, which it must lie under.''' try: relative = self._path.relative_to(other._path) except ValueError: @@ -462,14 +459,10 @@ def path(self) -> Path: ''' 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) + '''The root of this path's tree.''' + return self._derive(self._root) @property def relative(self) -> ConfinedPathComponent: @@ -503,11 +496,11 @@ def __repr__(self) -> str: 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)) + return ((self._root, self._path, self._untrusted) == + (other._root, other._path, other._untrusted)) def __hash__(self) -> int: - return hash((id(self._context), self._root, self._path)) + return hash((self._root, self._path, self._untrusted)) # File system queries (never following a link in an untrusted tree) diff --git a/dev_tools/pylibs/edgacknowledg/client/test/__init__.py b/dev_tools/pylibs/edgacknowledg/client/test/__init__.py index df8143127a..dd3054be6c 100644 --- a/dev_tools/pylibs/edgacknowledg/client/test/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/test/__init__.py @@ -36,6 +36,7 @@ Channel, ConfinedPath, IllegalFileAccessError, + SandboxContext, trunc_display_content, ) from edgutil import eprint @@ -172,13 +173,14 @@ def _assoc_files_path(test_home: ConfinedPath, async def run_test_source_handler(channel: Channel, + context: SandboxContext, 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. + from ``test_home`` or ``run_dir``, so it can't leave them, and commands + run on them (ifc-dump) in ``context``. ''' request_tag, request_value, _section = decode_response(await channel.recv()) is_multi_config_run = len(changes_files) > 1 @@ -516,7 +518,6 @@ async def form_curr_diff_text(config_name: str, } )) elif path_to_show.suffix == '.ifc': - context = test_home.context cmd_args = context.command('ifc-dump', path_to_show) # Execute the ifc-dump command. @@ -585,15 +586,15 @@ class TestSourceHandler: def __init__(self, section_name: str, slug: str, + context: SandboxContext, test_home: ConfinedPath, root: ConfinedPath) -> None: - '''``test_home`` and the run directory ``root`` come from one - ``SandboxContext``, which requests reach files only through. + '''Requests reach files only through ``test_home`` and the run + directory ``root``, and run commands in ``context``. ''' - 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._context = context self._test_home = test_home self._root = root self._changes_files = { @@ -617,6 +618,7 @@ def slug(self) -> str: async def handle_request(self, channel: Channel) -> None: await run_test_source_handler( channel, + self._context, self._test_home, self._root, self._changes_files diff --git a/dev_tools/pylibs/edgacknowledg/forge/publisher.py b/dev_tools/pylibs/edgacknowledg/forge/publisher.py index 1aa9c71341..79ae329d55 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/publisher.py +++ b/dev_tools/pylibs/edgacknowledg/forge/publisher.py @@ -195,11 +195,13 @@ def _build_handler(self, section: SectionRecord) -> ReviewClientHandler: return TestSourceHandler( section.name, section.slug, + context, context.subpath(section.home), context.subpath(section.root) ) if section.kind == KIND_BENCH: session = BenchReviewSession( + context = context, run_dir = context.subpath(section.root), baseline_dir = context.subpath(Path(section.details['baseline_dir'])), tag = section.details['tag'] diff --git a/dev_tools/services/acknowledg-forge/tests/test_symlinks.py b/dev_tools/services/acknowledg-forge/tests/test_symlinks.py index 88e1b58861..296b617c55 100644 --- a/dev_tools/services/acknowledg-forge/tests/test_symlinks.py +++ b/dev_tools/services/acknowledg-forge/tests/test_symlinks.py @@ -267,6 +267,7 @@ async def test_show_source_symlinks(tmp_path: Path, secret: Path) -> None: test_provider.TestSourceHandler( 'Test', 'test', + context, context.subpath(home), context.subpath(root) ) @@ -301,6 +302,7 @@ async def test_illegal_access_is_reported(tmp_path: Path, handler = test_provider.TestSourceHandler( 'Test', 'test', + context, context.subpath(home), context.subpath(root) ) @@ -417,8 +419,6 @@ def test_confined_path_component(tmp_path: Path) -> None: 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: @@ -487,10 +487,6 @@ def test_sandbox_context_commands(tmp_path: Path) -> None: 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'] @@ -591,6 +587,7 @@ def test_bench_session_trees(tmp_path: Path) -> None: # ...but with it. context = SandboxContext(bench_home, elsewhere) local = BenchReviewSession( + context = context, run_dir = context.subpath(elsewhere), baseline_dir = resolve_baseline_dir( context.subpath(bench_home), @@ -600,13 +597,6 @@ def test_bench_session_trees(tmp_path: Path) -> None: ) 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') @@ -624,6 +614,7 @@ async def test_test_run_outside_home(tmp_path: Path, secret: Path) -> None: handler = test_provider.TestSourceHandler( 'Test', 'test', + context, context.subpath(home), context.subpath(run) ) From 9683185e3fdee20b7c545b2f51fb8272a3ba9cc0 Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Mon, 19 Oct 2026 17:38:35 +0000 Subject: [PATCH 6/9] Continued work refining [] --- .github/workflows/acknowledg-forge-deploy.yml | 2 +- .github/workflows/acknowledg-forge.yml | 8 +- dev_tools/docker/acknowledg-forge/Dockerfile | 12 +- .../edgacknowledg/client/bench/__init__.py | 25 +- .../pylibs/edgacknowledg/client/shared.py | 270 +++++++++------- .../edgacknowledg/client/test/__init__.py | 48 +-- dev_tools/pylibs/edgacknowledg/forge/http.py | 15 +- .../pylibs/edgacknowledg/forge/publisher.py | 128 ++++---- .../edgacknowledg/forge/publisher_worker.py | 93 +++--- .../pylibs/edgacknowledg/forge/sandbox.py | 10 +- .../pylibs/edgacknowledg/forge/webhooks.py | 7 +- dev_tools/pylibs/edgtest/runtest/__init__.py | 43 ++- dev_tools/services/acknowledg-forge/README.md | 110 +++---- .../services/acknowledg-forge/SANDBOXING.md | 305 ++++++++++++++++++ .../services/acknowledg-forge/apparmor/bwrap | 24 ++ .../acknowledg-forge/deploy-remote.py | 300 +++++++++++++++++ .../acknowledg-forge/deploy-remote.sh | 162 ---------- .../acknowledg-forge/tests/test_deploy.py | 149 +++++++++ .../acknowledg-forge/tests/test_github.py | 36 ++- .../acknowledg-forge/tests/test_gitlab.py | 30 +- .../tests/test_publisher_worker.py | 55 +++- .../acknowledg-forge/tests/test_safety.py | 18 +- .../acknowledg-forge/tests/test_state.py | 7 +- .../acknowledg-forge/tests/test_symlinks.py | 142 +++++++- .../services/acknowledg-forge/typecheck.sh | 3 +- 25 files changed, 1452 insertions(+), 550 deletions(-) create mode 100644 dev_tools/services/acknowledg-forge/SANDBOXING.md create mode 100644 dev_tools/services/acknowledg-forge/apparmor/bwrap create mode 100755 dev_tools/services/acknowledg-forge/deploy-remote.py delete mode 100644 dev_tools/services/acknowledg-forge/deploy-remote.sh create mode 100644 dev_tools/services/acknowledg-forge/tests/test_deploy.py diff --git a/.github/workflows/acknowledg-forge-deploy.yml b/.github/workflows/acknowledg-forge-deploy.yml index 794019a309..c1292cd5b4 100644 --- a/.github/workflows/acknowledg-forge-deploy.yml +++ b/.github/workflows/acknowledg-forge-deploy.yml @@ -11,7 +11,7 @@ # 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 +# dev_tools/services/acknowledg-forge/deploy-remote.py, 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) diff --git a/.github/workflows/acknowledg-forge.yml b/.github/workflows/acknowledg-forge.yml index f2288c1fec..fa64fc9d83 100644 --- a/.github/workflows/acknowledg-forge.yml +++ b/.github/workflows/acknowledg-forge.yml @@ -47,12 +47,16 @@ jobs: uses: astral-sh/setup-uv@v10.3.0 # The sandbox tests need bubblewrap, which needs unprivileged user - # namespaces (restricted by AppArmor on Ubuntu 24.04 runners). + # namespaces. Ubuntu runners restrict those with AppArmor, so allow + # them for bubblewrap only, as SANDBOXING.md says to on such hosts. - name: Install bubblewrap run: | sudo apt-get update -q sudo apt-get install -y -q bubblewrap - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + sudo install -m 644 \ + dev_tools/services/acknowledg-forge/apparmor/bwrap \ + /etc/apparmor.d/bwrap + sudo apparmor_parser --replace /etc/apparmor.d/bwrap - name: Typecheck run: dev_tools/services/acknowledg-forge/typecheck.sh diff --git a/dev_tools/docker/acknowledg-forge/Dockerfile b/dev_tools/docker/acknowledg-forge/Dockerfile index 0ae0e3088b..e731a5349b 100644 --- a/dev_tools/docker/acknowledg-forge/Dockerfile +++ b/dev_tools/docker/acknowledg-forge/Dockerfile @@ -8,15 +8,14 @@ # dev_tools/services/acknowledg-forge/README.md for how to run it (rootless # Podman; bubblewrap inside the container needs --security-opt # unmask=/proc/*). +# +# The image is public: it carries no credentials or deployment configuration +# (those come from the host at run time). It has no default command either; +# whoever runs it picks the forge (acknowledg-forge-github or +# acknowledg-forge-gitlab). 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/ @@ -53,4 +52,3 @@ ENV ACKNOWLEDG_FORGE_HOME=/var/lib/acknowledg-forge USER acknowledg WORKDIR /var/lib/acknowledg-forge -CMD ["acknowledg-forge-github"] diff --git a/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py b/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py index ab9065a75e..755330ed8e 100644 --- a/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/bench/__init__.py @@ -132,26 +132,21 @@ 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). + ``baseline_dir``. ''' benchmark_metadatas: List[BenchmarkMetadata] = [] - for directory, dirs, files in run_dir.walk(): + for root, dirs, files in run_dir.walk(): for file_name in files: - file_path = directory / file_name + file_path = root / file_name if file_path.suffix != '.cgout': continue rel_benchmark_path = file_path.relative_to(run_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 + benchmark_metadata = collect_benchmark_metadata( + rel_benchmark_path, + run_dir, + baseline_dir + ) if benchmark_metadata is None: continue benchmark_metadatas.append(benchmark_metadata) @@ -170,8 +165,8 @@ def get_cg_annotate_output(context: SandboxContext, str(TOOL_PATH_CG_ANNOTATE), '--no-annotate', '--diff', - baseline_path, - run_path + str(baseline_path), + str(run_path) ), stdin = subprocess.DEVNULL, stdout = subprocess.PIPE, diff --git a/dev_tools/pylibs/edgacknowledg/client/shared.py b/dev_tools/pylibs/edgacknowledg/client/shared.py index fd22895822..38c455fe4e 100644 --- a/dev_tools/pylibs/edgacknowledg/client/shared.py +++ b/dev_tools/pylibs/edgacknowledg/client/shared.py @@ -79,7 +79,7 @@ async def handle_request(self, channel: Channel) -> None: ReviewClientHandler = Any # type: ignore[misc,assignment] -from edgutil import get_running_event_loop +from edgutil import get_running_event_loop, is_path_relative_to CoroFactory = Callable[[], Awaitable[Any]] @@ -187,94 +187,17 @@ def __init__(self, *args: Any) -> None: 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 make ``ConfinedPath``s with one (``subpath``), reach every file - through them, and run every command through the context itself - (``command``), which they are handed alongside the paths. - - - ``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. +class FileTooLargeError(Exception): + '''Raised when a request would read a file larger than its context + allows. Unlike ``IllegalFileAccessError`` the request was legitimate; + it just fails. ''' - 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(root, path, - allow_symlinks = self._allow_symlinks) - 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`` (so they were validated), never as plain - paths; the command only sees those under this context's roots. - ''' - command = list(self._command_prefix) - for arg in args: - if isinstance(arg, ConfinedPath): - 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 +# Bytes a file read for a reviewer may have, by default: generous for +# sources, recordings, and logs, but bounded, so a large file (say, in a +# pull request) can't exhaust the memory of whoever serves it. +DEFAULT_MAX_FILE_SIZE = 64 * 1024 * 1024 class ConfinedPathComponent: @@ -340,7 +263,8 @@ class ConfinedPath: validated. A missing path is valid; reading it fails as usual. The root itself is - trusted: whoever makes the tree vouches for it. + trusted: whoever makes the tree vouches for it. Opening a file larger + than ``max_file_size`` (if any) raises ``FileTooLargeError``. Its tree and policy are given when it is made (usually by ``SandboxContext.subpath``, from one of the context's roots and its @@ -355,18 +279,21 @@ def __init__(self, root: Path, path: Path, *, - allow_symlinks: bool) -> None: + allow_symlinks: bool, + max_file_size: Optional[int]) -> None: '''``path``, confined to the tree at ``root``; usually made by ``SandboxContext.subpath``. ''' self._root = root self._path = path - self._untrusted = not allow_symlinks + self._allow_symlinks = allow_symlinks + self._max_file_size = max_file_size self._validate() def _derive(self, path: Path) -> 'ConfinedPath': return ConfinedPath(self._root, path, - allow_symlinks = not self._untrusted) + allow_symlinks = self._allow_symlinks, + max_file_size = self._max_file_size) def _illegal(self, path: Optional[Path] = None) -> IllegalFileAccessError: return IllegalFileAccessError( @@ -385,11 +312,8 @@ def _relative_parts(self, path: Path) -> List[str]: 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): + if self._allow_symlinks: + if not is_path_relative_to(self._path.resolve(), self._root.resolve()): raise self._illegal() return current = self._root @@ -489,26 +413,29 @@ def __str__(self) -> str: return str(self._path) def __repr__(self) -> str: - links = 'no links' if self._untrusted else 'links inside' + links = 'links inside' if self._allow_symlinks else 'no links' 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._root, self._path, self._untrusted) == - (other._root, other._path, other._untrusted)) + return self._key() == other._key() def __hash__(self) -> int: - return hash((self._root, self._path, self._untrusted)) + return hash(self._key()) + + def _key(self) -> Tuple[Path, Path, bool, Optional[int]]: + return (self._root, self._path, self._allow_symlinks, + self._max_file_size) # 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 + if self._allow_symlinks: + return os.stat(self._path).st_mode + return os.lstat(self._path).st_mode except (FileNotFoundError, NotADirectoryError): return None @@ -537,7 +464,7 @@ def has_type(directory: str, name: str, is_type: Any) -> bool: self._validate() for directory, dirs, files in os.walk(self._path): - if self._untrusted: + if not self._allow_symlinks: 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)] @@ -552,9 +479,23 @@ def open(self, mode: str = 'r', **kwargs: Any) -> IO[Any]: 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) + if self._allow_symlinks: + handle = open(self._path, mode, **kwargs) + else: + handle = self._open_without_links(mode, **kwargs) + try: + # The size of what was opened; the trees served don't change while + # they're read. + size = os.fstat(handle.fileno()).st_size + if self._max_file_size is not None and size > self._max_file_size: + raise FileTooLargeError( + f"\"{self._path}\" is too large to read ({size} bytes; the " + f"limit is {self._max_file_size})" + ) + except BaseException: + handle.close() + raise + return handle def read_text(self, **kwargs: Any) -> str: with self.open('r', **kwargs) as handle: @@ -565,30 +506,143 @@ def read_bytes(self) -> bytes: return bytes(handle.read()) def _open_without_links(self, mode: str, **kwargs: Any) -> IO[Any]: + '''Open this path for reading without following a link anywhere + along it, in a tree that doesn't allow links. + + ``_validate`` already checked the path, but a plain ``open`` would + resolve it all over again: a component replaced by a link after the + check (or a FIFO put in place of the file) would be followed (or + waited on). So the open enforces the rules itself, one component at a + time, and checks what it actually opened. (``openat2`` with + ``RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS`` does the same in one call, + but Python has no wrapper for it.) + ''' parts = self._relative_parts(self._path) + # The root itself is a directory; there's no file to open. if len(parts) == 0: raise self._illegal() + + # O_NOFOLLOW only applies to the last component of the name it's given, + # so every name opened below is a single component, relative to the + # directory opened before it (dir_fd); none is resolved through a link. + # O_CLOEXEC keeps the descriptors from leaking into commands we run. flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0) dir_fd = os.open(str(self._root), flags | os.O_DIRECTORY) try: + # Descend through the directories. O_DIRECTORY with O_NOFOLLOW refuses + # a link to a directory (ELOOP, or ENOTDIR on some systems) and + # anything else that isn't a directory (ENOTDIR). 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. + # Open the file itself. O_NOFOLLOW refuses a link (ELOOP), and + # O_NONBLOCK makes opening a FIFO return at once instead of waiting + # for a writer (it's then refused below, like any non-regular file). fd = os.open(parts[-1], flags | os.O_NONBLOCK, dir_fd = dir_fd) except OSError as ex: + # A link (or a non-directory where a directory should be) is an + # attempt to leave the tree; report it like any other. Anything else + # (e.g. a missing file) is an ordinary error. if ex.errno in (errno.ELOOP, errno.ENOTDIR): raise self._illegal() from ex raise finally: os.close(dir_fd) + try: + # Check what was actually opened (not what _validate saw): only a + # regular file may be read, never a FIFO, device, or socket. if not stat.S_ISREG(os.fstat(fd).st_mode): raise self._illegal() + # Regular files don't block anyway; restore normal reads. os.set_blocking(fd, True) + # The file object takes ownership of the descriptor. return open(fd, mode, **kwargs) except BaseException: os.close(fd) raise + + +# 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 make ``ConfinedPath``s with one (``subpath``), reach every file + through them, and run every command through the context itself + (``command``), which they are handed alongside the paths. + + - ``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. + - ``max_file_size``: bytes a file read may have (None for no limit). + ''' + + def __init__(self, + *roots: Path, + allow_symlinks: bool = True, + command_prefix: Sequence[str] = (), + command_timeout: float = DEFAULT_COMMAND_TIMEOUT_S, + max_file_size: Optional[int] = DEFAULT_MAX_FILE_SIZE) -> 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_path_relative_to(root, other) for other in reduced): + reduced = [other for other in reduced + if not is_path_relative_to(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 + self._max_file_size = max_file_size + + @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 + + @property + def max_file_size(self) -> Optional[int]: + return self._max_file_size + + 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_path_relative_to(path, root): + return ConfinedPath(root, path, + allow_symlinks = self._allow_symlinks, + max_file_size = self._max_file_size) + raise IllegalFileAccessError( + f"A request was made attempting to retrieve the file: \"{path}\"!" + ) + + def command(self, *args: str) -> List[str]: + '''The argument vector running ``args`` in this context. Files should + come from ``ConfinedPath``s (so they were validated); the command only + sees those under this context's roots. + ''' + return [*self._command_prefix, *args] diff --git a/dev_tools/pylibs/edgacknowledg/client/test/__init__.py b/dev_tools/pylibs/edgacknowledg/client/test/__init__.py index dd3054be6c..f4935e8522 100644 --- a/dev_tools/pylibs/edgacknowledg/client/test/__init__.py +++ b/dev_tools/pylibs/edgacknowledg/client/test/__init__.py @@ -8,6 +8,7 @@ import asyncio import os import re +import shutil import sys import tempfile import traceback @@ -25,6 +26,7 @@ ) from edgacknowledg import ( + MAX_DISPLAY_CONTENT_SIZE, ProtocolMessage, RequestTags, WebSocketsMessageType, @@ -140,11 +142,13 @@ def _test_if_any_output_exists(recordings_dir: ConfinedPath, 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. + through its checks, for tools that take a plain path. The copy streams, + so a large recording isn't held in memory. ''' if recording is None: return None - destination.write_bytes(recording.read_bytes()) + with recording.open('rb') as source, open(destination, 'wb') as target: + shutil.copyfileobj(source, target) return destination @@ -304,8 +308,7 @@ async def form_curr_diff_text(config_name: str, tmp_dir = Path(tmp_dir_name) entries: List[Tuple[Dict[str, Any], List[str]]] = [] - with edgtest.runtest.RunTestOutput( - case_rt_path.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 @@ -463,16 +466,18 @@ async def form_curr_diff_text(config_name: str, file_list = [] if assoc_files_path is not None and assoc_files_path.exists(): - for directory, dirs, filenames in assoc_files_path.walk(): - root = str(directory.path) + for root, dirs, filenames in assoc_files_path.walk(): dirs[:] = [ - d for d in dirs if edgtest.runtest.is_valid_assoc_path(root, d) + d for d in dirs + if edgtest.runtest.is_valid_assoc_path(str(root.path), d) ] for filename in filenames: - if not edgtest.runtest.is_valid_assoc_path(root, filename): + if not edgtest.runtest.is_valid_assoc_path( + str(root.path), filename + ): continue - file_path = directory / filename + file_path = root / filename # The relative path with / separators (to canonicalize the path # regardless of operating system). @@ -518,7 +523,7 @@ async def form_curr_diff_text(config_name: str, } )) elif path_to_show.suffix == '.ifc': - cmd_args = context.command('ifc-dump', path_to_show) + cmd_args = context.command('ifc-dump', str(path_to_show)) # Execute the ifc-dump command. proc = await asyncio.create_subprocess_exec( @@ -553,18 +558,21 @@ async def form_curr_diff_text(config_name: str, } )) else: + # Only as much as can be shown is read (one character more marks + # the content as truncated). with path_to_show.open(errors = 'replace') as file_handle: - await channel.send(message( - RequestTags.TEST_SHOW_SOURCE, - value = { - 'reviewer_id': reviewer_id, - 'client_response': { - 'id': test_id, - 'assoc_file': request_value.get('assoc_file'), - **trunc_display_content(file_handle.read()) - } + shown = file_handle.read(MAX_DISPLAY_CONTENT_SIZE + 1) + await channel.send(message( + RequestTags.TEST_SHOW_SOURCE, + value = { + 'reviewer_id': reviewer_id, + 'client_response': { + 'id': test_id, + 'assoc_file': request_value.get('assoc_file'), + **trunc_display_content(shown) } - )) + } + )) else: await channel.send(message( RequestTags.INVALID_REQUEST, diff --git a/dev_tools/pylibs/edgacknowledg/forge/http.py b/dev_tools/pylibs/edgacknowledg/forge/http.py index 396aac2c6a..1398076465 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/http.py +++ b/dev_tools/pylibs/edgacknowledg/forge/http.py @@ -15,6 +15,7 @@ import logging import time +from http import HTTPStatus from pathlib import Path from typing import Any, Mapping, Optional @@ -28,11 +29,18 @@ MAX_RATE_LIMIT_WAIT = 15 * 60.0 MAX_ATTEMPTS = 4 -_REDIRECTS = (301, 302, 303, 307, 308) +_REDIRECTS = ( + HTTPStatus.MOVED_PERMANENTLY, + HTTPStatus.FOUND, + HTTPStatus.SEE_OTHER, + HTTPStatus.TEMPORARY_REDIRECT, + HTTPStatus.PERMANENT_REDIRECT, +) def _rate_limit_wait(response: httpx.Response) -> Optional[float]: - if response.status_code not in (403, 429): + if response.status_code not in (HTTPStatus.FORBIDDEN, + HTTPStatus.TOO_MANY_REQUESTS): return None retry_after = response.headers.get('retry-after') if retry_after is not None and retry_after.isdigit(): @@ -102,7 +110,8 @@ async def request(self, _log.warning('rate limited; waiting %.0f s', wait) await asyncio.sleep(wait) continue - if response.status_code >= 500 and not last_attempt: + if (response.status_code >= HTTPStatus.INTERNAL_SERVER_ERROR and + not last_attempt): await asyncio.sleep(2 ** attempt) continue if response.is_error or response.status_code in _REDIRECTS: diff --git a/dev_tools/pylibs/edgacknowledg/forge/publisher.py b/dev_tools/pylibs/edgacknowledg/forge/publisher.py index 79ae329d55..c4b1fefd53 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/publisher.py +++ b/dev_tools/pylibs/edgacknowledg/forge/publisher.py @@ -17,6 +17,7 @@ import json import logging import os +import socket import sys import time import uuid @@ -278,13 +279,14 @@ async def _remove(self, review_id: uuid.UUID) -> None: await self._session.remove_review(client_review) -# Messages between ``SubprocessPublisher`` and ``publisher_worker``: one JSON +# Messages between ``SubprocessPublisher`` and ``publisher_worker``, over a +# socket the worker inherits (its file descriptor is its argument): 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'}). +# requests ({'op': 'sync' | 'remove', ...}), one at a time; the worker +# answers each ({'ok': true} or {'error': 'unavailable' | 'failed', +# 'message'}) and, at any time, reports its connection ({'event': +# 'connected' | 'disconnected'}). WORKER_MODULE = 'edgacknowledg.forge.publisher_worker' @@ -411,12 +413,14 @@ def __init__(self, self._restart_delay = restart_delay self._max_restart_delay = max_restart_delay + # The running worker and the parent's end of its socket. self._process: Optional[asyncio.subprocess.Process] = None + self._channel: Optional[asyncio.StreamWriter] = None self._connected = asyncio.Event() - # Serializes requests, and replaying after a restart. + # Serializes requests (so at most one awaits a reply), and replaying + # after a restart. self._lock = asyncio.Lock() - self._next_id = 0 - self._pending: dict[int, asyncio.Future[None]] = {} + self._reply: Optional[asyncio.Future[dict[str, Any]]] = None # What each review should serve, to replay to a restarted worker. self._desired: dict[uuid.UUID, tuple[ReviewIdentity, list[SectionRecord]]] = {} @@ -470,65 +474,67 @@ async def _sync(self, # Worker requests async def _request(self, request: dict[str, Any]) -> None: + '''Send ``request`` to the worker and wait for its answer; the caller + holds the lock. + ''' process = self._process - if process is None or process.stdin is None: + channel = self._channel + if process is None or channel is None: raise PublisherUnavailableError('the publisher worker is not running') - self._next_id += 1 - request_id = self._next_id - future: asyncio.Future[None] = ( + reply: asyncio.Future[dict[str, Any]] = ( asyncio.get_running_loop().create_future() ) - self._pending[request_id] = future + self._reply = reply try: - line = json.dumps({'id': request_id, **request}) + '\n' try: - process.stdin.write(line.encode()) - await process.stdin.drain() + channel.write((json.dumps(request) + '\n').encode()) + await channel.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) + answer = await asyncio.wait_for(asyncio.shield(reply), + self._request_timeout) except asyncio.TimeoutError: _log.error('the publisher worker did not answer within %s s; ' 'restarting it', self._request_timeout) + # Send it nothing more: a late answer must not be taken for the + # next request's. + self._process = None + self._channel = None self._kill(process) raise PublisherUnavailableError( 'the publisher worker stopped answering' ) from None finally: - self._pending.pop(request_id, None) + self._reply = None + + error = answer.get('error') + if error == 'unavailable': + raise PublisherUnavailableError(str(answer.get('message'))) + if error is not None: + raise RuntimeError(f"publisher worker: {answer.get('message')}") def _handle_message(self, line: bytes) -> None: try: value = json.loads(line) except ValueError: + value = None + if not isinstance(value, dict): _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': + elif 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'))) - ) + elif self._reply is not None and not self._reply.done(): + self._reply.set_result(value) else: - future.set_exception( - RuntimeError(f"publisher worker: {value.get('message')}") - ) + _log.error('unexpected answer from the publisher worker: %r', + line[:200]) async def _on_connected(self) -> None: '''Once the worker is connected, serve again what a previous worker @@ -555,9 +561,9 @@ async def _on_connected(self) -> None: # Supervision - def _worker_command(self) -> list[str]: + def _worker_command(self, channel_fd: int) -> list[str]: python_paths = _python_paths() - command = [sys.executable, '-m', WORKER_MODULE] + command = [sys.executable, '-m', WORKER_MODULE, str(channel_fd)] env = { 'HOME': '/tmp', 'PYTHONUTF8': '1', @@ -637,34 +643,46 @@ async def run(self) -> None: 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(), + parent_end, worker_end = socket.socketpair() + try: + process = await asyncio.create_subprocess_exec( + *self._worker_command(worker_end.fileno()), + stdin = asyncio.subprocess.DEVNULL, + env = self._worker_env(), + pass_fds = (worker_end.fileno(),) + ) + except BaseException: + parent_end.close() + raise + finally: + # Only the worker holds its end, so it closing (or exiting) ends the + # messages. + worker_end.close() + messages, channel = await asyncio.open_unix_connection( + sock = parent_end, limit = MESSAGE_LIMIT ) - assert process.stdin is not None and process.stdout is not None self._process = process + self._channel = channel try: - process.stdin.write( - (json.dumps(self._init_message()) + '\n').encode() - ) - await process.stdin.drain() - while True: - line = await process.stdout.readline() + channel.write((json.dumps(self._init_message()) + '\n').encode()) + await channel.drain() + while self._process is process: + line = await messages.readline() if line == b'': break self._handle_message(line) finally: - self._process = None + if self._process is process: + self._process = None + self._channel = 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') - ) + if self._reply is not None and not self._reply.done(): + self._reply.set_exception( + PublisherUnavailableError('the publisher worker exited') + ) + channel.close() self._kill(process) await process.wait() if self._replay_task is not None: diff --git a/dev_tools/pylibs/edgacknowledg/forge/publisher_worker.py b/dev_tools/pylibs/edgacknowledg/forge/publisher_worker.py index 36e20aea2e..c59129b638 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/publisher_worker.py +++ b/dev_tools/pylibs/edgacknowledg/forge/publisher_worker.py @@ -4,22 +4,22 @@ # 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). +``SubprocessPublisher`` over the socket whose file descriptor is its +argument (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 argparse import asyncio import json import logging -import os -import sys +import socket import uuid from pathlib import Path -from typing import Any, BinaryIO, Optional +from typing import Any, Optional from edgacknowledg.forge.publisher import ( MESSAGE_LIMIT, @@ -33,31 +33,24 @@ _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() +def _send(channel: asyncio.StreamWriter, value: dict[str, Any]) -> None: + channel.write((json.dumps(value) + '\n').encode()) class _WorkerPublisher(AcknowledgPublisher): '''Also reports the AcknowlEDG connection to the parent.''' - def __init__(self, output: _Output, **kwargs: Any) -> None: + def __init__(self, channel: asyncio.StreamWriter, **kwargs: Any) -> None: super().__init__(**kwargs) - self._output = output + self._channel = channel def connected(self) -> None: super().connected() - self._output.send({'event': 'connected'}) + _send(self._channel, {'event': 'connected'}) def disconnected(self) -> None: super().disconnected() - self._output.send({'event': 'disconnected'}) + _send(self._channel, {'event': 'disconnected'}) async def _handle(publisher: AcknowledgPublisher, @@ -74,39 +67,41 @@ async def _handle(publisher: AcknowledgPublisher, 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.''' +async def _serve_requests(messages: asyncio.StreamReader, + channel: asyncio.StreamWriter, + publisher: AcknowledgPublisher) -> None: + '''Answer requests, one at a time, until the parent closes the socket.''' while True: - line = await reader.readline() + line = await messages.readline() if line == b'': return request = json.loads(line) - reply: dict[str, Any] = {'id': request.get('id')} + answer: dict[str, Any] try: await _handle(publisher, request) - reply['ok'] = True + answer = {'ok': True} except PublisherUnavailableError as ex: - reply.update(error = 'unavailable', message = str(ex)) + answer = {'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 + answer = {'error': 'failed', 'message': f"{type(ex).__name__}: {ex}"} + _send(channel, answer) + await channel.drain() + + +async def _run(channel_fd: int) -> None: + sock = socket.socket(fileno = channel_fd) + # Commands the worker runs must not reach the parent. + sock.set_inheritable(False) + messages, channel = await asyncio.open_unix_connection( + sock = sock, + limit = MESSAGE_LIMIT ) - settings = json.loads(await reader.readline()) + settings = json.loads(await messages.readline()) sandbox_settings = settings['sandbox'] tools_dir: Optional[str] = sandbox_settings['tools_dir'] publisher = _WorkerPublisher( - output, + channel, api_host = settings['api_host'], api_port = int(settings['api_port']), api_key = settings['api_key'], @@ -123,23 +118,33 @@ async def _run(output: _Output) -> None: publisher_task = asyncio.create_task(publisher.run()) try: - await _serve_requests(reader, publisher, output) + await _serve_requests(messages, channel, publisher) finally: publisher_task.cancel() await asyncio.gather(publisher_task, return_exceptions = True) + channel.close() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description = 'Serve review sections for SubprocessPublisher.' + ) + parser.add_argument( + 'channel_fd', + type = int, + help = 'the inherited socket to the parent' + ) + return parser.parse_args() 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()) + args = parse_args() logging.basicConfig( level = logging.INFO, format = '%(levelname)s %(name)s (publisher worker): %(message)s' ) try: - asyncio.run(_run(output)) + asyncio.run(_run(args.channel_fd)) except KeyboardInterrupt: pass diff --git a/dev_tools/pylibs/edgacknowledg/forge/sandbox.py b/dev_tools/pylibs/edgacknowledg/forge/sandbox.py index 0a068b305f..7dbd22ca79 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/sandbox.py +++ b/dev_tools/pylibs/edgacknowledg/forge/sandbox.py @@ -9,7 +9,8 @@ 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). +own (hence bubblewrap 0.8 or later, for --disable-userns). Their /tmp is a +bounded tmpfs. The process serving reviewers (see ``publisher_worker``) runs sandboxed too, through ``service_prefix``: like a command, but with the network (to @@ -41,6 +42,11 @@ # current Fedora, ship newer ones). MIN_BWRAP_VERSION = (0, 8) +# Bytes a sandbox's /tmp may hold. It's a tmpfs, so what's written there is +# memory; bounding it keeps untrusted content (copied there to be diffed, +# say) from exhausting the host's. +TMPFS_SIZE = 512 * 1024 * 1024 + _BWRAP_VERSION = re.compile(r'^bubblewrap (\d+)\.(\d+)', re.MULTILINE) # What a networked process needs from /etc: name resolution and the @@ -235,7 +241,7 @@ def _common_args(self, *, read_only: Sequence[Path]) -> list[str]: '--ro-bind-try', '/etc/ld.so.cache', '/etc/ld.so.cache', '--proc', '/proc', '--dev', '/dev', - '--tmpfs', '/tmp', + '--size', str(TMPFS_SIZE), '--tmpfs', '/tmp', ] for system_dir in _SYSTEM_DIRS: if os.path.islink(system_dir): diff --git a/dev_tools/pylibs/edgacknowledg/forge/webhooks.py b/dev_tools/pylibs/edgacknowledg/forge/webhooks.py index 7359b5aac2..b6bfb847ab 100644 --- a/dev_tools/pylibs/edgacknowledg/forge/webhooks.py +++ b/dev_tools/pylibs/edgacknowledg/forge/webhooks.py @@ -13,6 +13,7 @@ import asyncio import logging +from http import HTTPStatus from typing import Any from quart import Quart, request @@ -74,13 +75,13 @@ async def receive_webhook() -> Any: 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 + return 'verification failed\n', HTTPStatus.UNAUTHORIZED for event in webhooks.parse(headers, body): events.put(event) - return '', 202 + return '', HTTPStatus.ACCEPTED @app.get('/healthz') async def healthz() -> Any: - return 'ok\n', 200 + return 'ok\n', HTTPStatus.OK return app diff --git a/dev_tools/pylibs/edgtest/runtest/__init__.py b/dev_tools/pylibs/edgtest/runtest/__init__.py index 396f0078bb..35ef6e7a3d 100644 --- a/dev_tools/pylibs/edgtest/runtest/__init__.py +++ b/dev_tools/pylibs/edgtest/runtest/__init__.py @@ -27,7 +27,7 @@ from collections import OrderedDict from pathlib import Path from typing import ( - Any, Callable, Dict, Iterator, List, Mapping, NamedTuple, Optional, + Any, Callable, Dict, IO, Iterator, List, Mapping, NamedTuple, Optional, Pattern, Set, Tuple, cast ) @@ -1281,12 +1281,35 @@ def finalize_rt_tar(contents: RtTarContents, } contents.pack(metadata, dest_path) +class RunTestOutputLinkError(ValueError): + '''Raised when a run artifact member that is read is a link.''' + +def _extract_tar_member(tar: tarfile.TarFile, + member_name: str) -> IO[bytes]: + '''Open a run artifact member, which must be a regular file. + + tarfile resolves a symlink or hard link member to another member of the + archive (never the file system), but links have no place in a run + artifact, may alias members, and can form cycles that exhaust the stack; + so they're refused (RunTestOutputLinkError) rather than followed. + ''' + try: + member = tar.getmember(member_name) + except KeyError: + raise FileNotFoundError(member_name) from None + if member.issym() or member.islnk(): + raise RunTestOutputLinkError( + f"{member_name} is a link to {member.linkname}" + ) + extracted = tar.extractfile(member) + if extracted is None: + raise FileNotFoundError(member_name) + return extracted + def _read_tar_lines_member(tar: tarfile.TarFile, member_name: str) -> List[str]: '''Read a tar member as a list of lines (keepends=True).''' - extracted = tar.extractfile(member_name) - if extracted is None: - raise FileNotFoundError(member_name) + extracted = _extract_tar_member(tar, member_name) with io.TextIOWrapper( extracted, encoding = 'utf-8', @@ -1329,9 +1352,7 @@ def _read_lines(self, member_name: str) -> List[str]: @property def metadata(self) -> Dict[str, Any]: if self._metadata is None: - extracted = self._require_tar().extractfile('metadata.json') - if extracted is None: - raise FileNotFoundError('metadata.json') + extracted = _extract_tar_member(self._require_tar(), 'metadata.json') with io.TextIOWrapper( extracted, encoding = 'utf-8', @@ -1360,9 +1381,7 @@ def extract_command_output(self, dest: Path) -> None: '''Stream cases///output.txt to dest.''' member = f"cases/{case_number}/{command_number}/output.txt" - extracted = self._require_tar().extractfile(member) - if extracted is None: - raise FileNotFoundError(member) + extracted = _extract_tar_member(self._require_tar(), member) with open(dest, 'wb') as out_handle: shutil.copyfileobj(extracted, out_handle) @@ -1385,9 +1404,7 @@ def case_matches(self, case_number: int) -> Optional[List[str]]: def extract_case_matches(self, case_number: int, dest: Path) -> None: '''Stream cases//matches/output.txt to dest.''' member = f"cases/{case_number}/matches/output.txt" - extracted = self._require_tar().extractfile(member) - if extracted is None: - raise FileNotFoundError(member) + extracted = _extract_tar_member(self._require_tar(), member) with open(dest, 'wb') as out_handle: shutil.copyfileobj(extracted, out_handle) diff --git a/dev_tools/services/acknowledg-forge/README.md b/dev_tools/services/acknowledg-forge/README.md index 25a7bfe6ae..4f784e04f4 100644 --- a/dev_tools/services/acknowledg-forge/README.md +++ b/dev_tools/services/acknowledg-forge/README.md @@ -51,50 +51,24 @@ 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, - 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. -- Sections are read-only. Reviewers can't accept diffs or update baselines +Pull requests, and the CI artifacts their runs produce, are untrusted. +[SANDBOXING.md](SANDBOXING.md) describes in detail how the service handles +them; in short: + +- The EDG tooling it runs is a frozen, reviewed copy, never a pull + request's. +- Everything that reads pull request content runs under + [bubblewrap](https://github.com/containers/bubblewrap), away from the + service's credentials, database, and clones: the commands that build + sections, and a separate process (`publisher_worker`) that answers + reviewers. +- Checkouts, artifacts, and what sandboxed commands write are checked for + symlinks and other links before anything reads them, and reviewers' reads + never follow one. +- Downloads, files read for reviewers, commands, and the service's memory + are all limited. +- 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. ## Host requirements @@ -106,13 +80,15 @@ image pins every dependency: - 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. +The host then needs only rootless Podman (with Quadlet), systemd, network +access to the forge and the AcknowlEDG API, and, for deploying from CI, +`python3` (3.8 or later; the deploy helper uses only the standard library). Running directly on a host instead needs Python 3.11 or later, git 2.35 or -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`. +later, bubblewrap 0.8 or later with unprivileged user namespaces (see +[SANDBOXING.md](SANDBOXING.md#host-requirements), including for hosts using +AppArmor), `python3`, `patch`, `diff` and `cg_annotate` under `/usr`, and a +`dev_tools` copy for `ACKNOWLEDG_FORGE_TOOLS_DIR`. ## Configuration @@ -189,7 +165,9 @@ 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. +job has no access to the deployment's secrets. It has no default command: +the unit's `Exec=` picks the forge's service (`acknowledg-forge-github` or +`acknowledg-forge-gitlab`). To build locally, run `build-edg-env acknowledg-forge`; the result is tagged `ghcr.io/edgcpp/acknowledg-forge:latest`. @@ -360,6 +338,8 @@ LoadCredentialEncrypted=webhook-secret:/etc/credstore.encrypted/acknowledg-forge # 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 +# A ceiling on the memory of the service, its worker, and their commands. +MemoryMax=4G # Starting may include pulling the image. TimeoutStartSec=600 Restart=on-failure @@ -380,14 +360,14 @@ that user run it, and only it, as root: ``` install -m 0755 -o root -g root \ - dev_tools/services/acknowledg-forge/deploy-remote.sh \ + dev_tools/services/acknowledg-forge/deploy-remote.py \ /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, +The helper ([`deploy-remote.py`](deploy-remote.py), which needs only the host's +`python3`) 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 @@ -437,12 +417,9 @@ settings so the host can pull it without logging in. Notes: - **Isolation:** the container keeps the service and its dependencies away - 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. + from the host; inside it, bubblewrap isolates whatever reads pull request + content from the service (see [SANDBOXING.md](SANDBOXING.md)), which is + why the unit unmasks `/proc`. - **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. @@ -456,7 +433,8 @@ Install a service wheel (`build.sh`) into a virtual environment and run `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. +`RestrictNamespaces=` or `PrivateUsers=`, which would stop bubblewrap (see +[SANDBOXING.md](SANDBOXING.md#host-requirements)). ## Development @@ -487,17 +465,3 @@ Implement `Forge`, and optionally `ForgeWebhooks` and `ForgeCommenter` 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. -- 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. diff --git a/dev_tools/services/acknowledg-forge/SANDBOXING.md b/dev_tools/services/acknowledg-forge/SANDBOXING.md new file mode 100644 index 0000000000..72acf4e755 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/SANDBOXING.md @@ -0,0 +1,305 @@ +# Sandboxing in acknowledg-forge + +acknowledg-forge builds review sections out of pull requests and the CI +artifacts their runs produce, and serves them to reviewers. All of that +content is untrusted: anyone who can open a pull request controls it. This +document describes how the service keeps that content away from what it +protects, and what a host must provide for it to work. + +## What is protected + +The service holds things a pull request must never reach: + +- the forge token (which can comment on, and read, every served repository) + and the webhook secret; +- the database; +- the clones, and every other pull request's review trees; +- the host itself, and the service's memory and disk. + +The AcknowlEDG API key is the one secret the process serving reviewers +holds; it only lets that process publish review sections. + +## Layers + +No single mechanism is relied on. In order, from outermost: + +1. **The container.** Rootless Podman keeps the service and its dependencies + away from the host (see [README.md](README.md#deployment)). +2. **What enters the service.** The EDG tooling comes from a frozen, + reviewed copy (`ACKNOWLEDG_FORGE_TOOLS_DIR`; the image embeds one), never + from a pull request. A pull request's diff is only ever input to `patch`. + Checkouts and artifacts are checked before anything reads them (see + [Symlinks and links](#symlinks-and-links)). +3. **bubblewrap.** Everything that reads pull request content runs in a + [bubblewrap](https://github.com/containers/bubblewrap) sandbox that sees + only what it needs: the commands that build sections, the process that + answers reviewers, and the commands that process runs. +4. **Confined reads.** The code answering reviewers opens files without + following symlinks, only under the section's tree, and only up to a size + limit. +5. **Resource limits.** Timeouts, size caps, bounded `/tmp`s, and the unit's + `MemoryMax=`. + +## Processes + +```mermaid +flowchart TB + forge[(Forge API)] + acknowledg[(AcknowlEDG API)] + + subgraph container [Container: rootless Podman] + subgraph service [Service process] + secrets[/forge token, webhook secret/] + db[(database)] + clones[(clones)] + end + + subgraph build [bubblewrap: build commands] + rematerialize[edg-rematerialize-test-run, patch] + end + + subgraph worker [bubblewrap: publisher worker] + handlers[review section handlers] + subgraph leaf [bubblewrap: reviewer commands] + tools[ifc-dump, cg_annotate] + end + end + + reviews[(reviews/: worktrees and run directories)] + end + + forge <-->|HTTPS| service + service -->|starts: worktree read-only, staging writable| build + service <-->|socket: sync and remove requests| worker + worker <-->|WebSocket, API key| acknowledg + handlers -->|starts, with the section read-only| leaf + build -.->|writes staging| reviews + service -.->|checks out, extracts artifacts| reviews + worker -.->|reads, read-only| reviews +``` + +The service process is the only one holding the forge token, the webhook +secret, the database, and the clones. It never reads pull request content +beyond checking its shape; reading it is left to sandboxed processes. + +### What each sandbox sees + +| | Build commands | Publisher worker | Reviewer commands | +| --- | --- | --- | --- | +| Started by | the service | the service | the publisher worker | +| Runs | `edg-rematerialize-test-run` (and `patch`) | `edgacknowledg.forge.publisher_worker` | `ifc-dump`, `cg_annotate` | +| Reads | `/usr`, the tools, the worktree | `/usr`, the tools, the Python installation, `reviews/` | `/usr`, the tools, the section's tree | +| Writes | its staging directory | nothing | nothing | +| `/tmp` | tmpfs, 512 MiB | tmpfs, 512 MiB | tmpfs, 512 MiB | +| Network | none | yes (AcknowlEDG), with DNS and TLS configuration from `/etc` | none | +| Environment | cleared, but for what it needs (`PATH`, `HOME`, ...) | the same | the same | +| Capabilities | none | none | none | +| New user namespaces | refused (`--disable-userns`) | allowed, for its own sandboxes | refused (`--disable-userns`) | +| Time limit | `ACKNOWLEDG_FORGE_REMATERIALIZE_TIMEOUT` (15 min) | 15 min per request, else restarted | 2 min | + +Every sandbox also gets fresh PID, IPC, UTS, and cgroup namespaces, its own +`/proc` and `/dev`, a new session (so it can't inject input into a +terminal), and dies with its parent. + +bubblewrap follows symlinks in the paths it binds, so the service binds +only directories it created, or checked to be real directories +(`require_real_directory`). + +## Building a section + +```mermaid +sequenceDiagram + autonumber + participant F as Forge + participant S as Service + participant B as bubblewrap: build + participant W as Publisher worker + + S->>F: fetch the pull request + Note over S: fsck what was fetched + Note over S: check out its merge-base,
symlinks as plain files + Note over S: check_plain_tree(worktree) + S->>F: download the CI artifact (size-capped) + Note over S: extract it: no links, no escaping paths,
inner tarballs size-capped + S->>B: edg-rematerialize-test-run diff + Note over B: worktree read-only,
staging writable,
time-limited + B-->>S: exit status + Note over S: check_plain_tree(staging),
no hard links either + Note over S: rename staging into place + S->>W: sync(review, sections) + W-->>S: ok + Note over S: record the sections,
delete superseded run directories +``` + +A failed check fails the section rather than serving it. Superseded run +directories are deleted only after the worker confirms it no longer serves +them. + +## Serving reviewers + +```mermaid +sequenceDiagram + participant R as Reviewer + participant A as AcknowlEDG + participant W as Publisher worker + participant C as Reviewer command
(bubblewrap) + + R->>A: show a test's source + A->>W: request + Note over W: ConfinedPath.open: no symlinks,
under the section, at most 64 MiB + W-->>A: the contents (only as much as can be displayed) + A-->>R: response + + R->>A: show an IFC dump + A->>W: request + W->>C: ifc-dump path + Note over C: section read-only, 2 min + C-->>W: output + W-->>A: response +``` + +Sections are read-only: reviewers can't accept diffs or update baselines +through the service. + +### The publisher worker + +Answering reviewers means reading pull request content, so it happens in a +separate process, `publisher_worker`, rather than in the service. The worker +wraps the same `AcknowledgPublisher` that a local review uses; the service +drives it through `SubprocessPublisher`. + +They talk over a Unix socket pair: the service keeps one end and passes the +other to the worker (its file descriptor is the worker's only argument). +Messages are JSON objects, one per line: + +```mermaid +sequenceDiagram + participant S as Service
(SubprocessPublisher) + participant W as Publisher worker + + S->>W: settings, including the AcknowlEDG API key + W-->>S: {"event": "connected"} + S->>W: {"op": "sync", "review": ..., "sections": [...]} + W-->>S: {"ok": true} + W-->>S: {"event": "disconnected"} + S->>W: {"op": "remove", "review_id": ...} + W-->>S: {"error": "unavailable", "message": ...} +``` + +- The API key goes over the socket, not the command line or environment, + where `/proc` would show it. +- Requests go one at a time, so each answer belongs to the request in + flight. `unavailable` (not connected to AcknowlEDG) defers the pull + request until the connection is back; any other error fails it. +- If the worker exits, or doesn't answer a request within 15 minutes (it is + then killed, and sent nothing more), the service restarts it, with a delay + doubling from 1 s up to 60 s. Once the new worker connects, the service + serves every review again before sending anything new. +- The worker's commands don't inherit its end of the socket. + +At startup, the service checks that bubblewrap can run nested this way +(`Sandbox.check`), and refuses to start otherwise. + +## Symlinks and links + +A pull request can commit symlinks, and sandboxed commands could create +them. Several layers keep the service from reading through one: + +- 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 fsck everything they fetch (`transfer.fsckObjects`), refusing + malformed trees. +- After every checkout, a worktree may hold only directories and regular + files (`check_plain_tree`). So may what `edg-rematerialize-test-run` + writes, which also may not hold hard links. +- The section's directories, including the bench baselines a pull request + supplies, must be real directories. +- CI artifacts may not contain links or escaping paths, nor may the + `.rt.tar.gz` files inside them. Reading those tarballs later refuses link + members as well. +- The handlers open files through `ConfinedPath`, which opens each path + component with `O_NOFOLLOW` relative to the last, so a symlink anywhere + on the way is refused, even one created after a check. A refused path + ends the reviewer's connection. +- The service refuses to write through symlinks. + +## Resource limits + +| Resource | Limit | +| --- | --- | +| Artifact download | `ACKNOWLEDG_FORGE_MAX_ARTIFACT_BYTES` (256 MiB), also counting the decompressed size of inner tarballs | +| A file read for a reviewer | 64 MiB; a larger one fails the request | +| A source shown to a reviewer | only as much as can be displayed is read | +| Each sandbox's `/tmp` | 512 MiB tmpfs; copies made for diffing are streamed there | +| Build commands | `ACKNOWLEDG_FORGE_REMATERIALIZE_TIMEOUT` (15 min) | +| Reviewer commands | 2 min | +| The whole service | the unit's `MemoryMax=` (see [README.md](README.md#host-setup-once)) | + +## Secrets + +Secrets belong in systemd 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 the service posts contain only fixed text, commit hashes, and +links it generates. + +## Host requirements + +### bubblewrap + +The service requires bubblewrap 0.8 or later (for `--disable-userns`) and +refuses to start with an older one. The container image ships a suitable +version. + +bubblewrap needs unprivileged user namespaces, nested one deep (the +worker's sandboxes inside its own). So: + +- In the container, Podman's masking of `/proc` paths stops bubblewrap + from mounting its own `/proc`; the unit unmasks them + (`PodmanArgs=--security-opt unmask=/proc/*`). That only affects what the + container's `/proc` shows: in a rootless container those files stay + unreadable to it. +- Under systemd without a container, don't add `RestrictNamespaces=` or + `PrivateUsers=`, which would stop bubblewrap. + +### AppArmor + +Some hosts (Ubuntu 24.04 and later) use AppArmor to stop unconfined +programs from creating user namespaces, which bubblewrap needs. Don't lift +that restriction (`kernel.apparmor_restrict_unprivileged_userns=0`): it +covers every program on the host. Instead, give bubblewrap a profile that +allows it, [`apparmor/bwrap`](apparmor/bwrap): + +``` +sudo install -m 644 apparmor/bwrap /etc/apparmor.d/bwrap +sudo apparmor_parser --replace /etc/apparmor.d/bwrap +``` + +It attaches to `/usr/bin/bwrap`, so it also covers the nested bubblewrap +that the publisher worker runs. If the host already has a profile for +`/usr/bin/bwrap` (`grep -l /usr/bin/bwrap /etc/apparmor.d/*`), check that +it allows `userns` rather than adding a second one. To check the result: + +``` +bwrap --unshare-user --ro-bind / / true && echo ok +``` + +## Development + +`ACKNOWLEDG_FORGE_UNSANDBOXED=1` runs everything without bubblewrap, for +local development only. The publisher worker is still a separate process, +with a cleared environment, but it sees everything the service does. + +The tests covering this are in [`tests/`](tests): + +- `test_safety.py`: the sandbox itself (what commands can see and write, + timeouts, user namespaces, `/tmp`'s bound) and artifact extraction; +- `test_symlinks.py`: checkouts, sandbox output, confined reads, and run + output tarballs; +- `test_publisher_worker.py`: what the worker can see, its restarts, and + unanswered requests; +- `test_acknowledg_integration.py`: malicious reviewer requests against a + real AcknowlEDG server. diff --git a/dev_tools/services/acknowledg-forge/apparmor/bwrap b/dev_tools/services/acknowledg-forge/apparmor/bwrap new file mode 100644 index 0000000000..a4f4b0cf33 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/apparmor/bwrap @@ -0,0 +1,24 @@ +# 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 +# +# Lets bubblewrap create user namespaces on hosts that restrict them for +# unconfined programs (kernel.apparmor_restrict_unprivileged_userns=1, the +# default on Ubuntu 24.04 and later), instead of lifting that restriction +# for every program. Install it as /etc/apparmor.d/bwrap, then load it: +# +# sudo apparmor_parser --replace /etc/apparmor.d/bwrap +# +# Like the profiles Ubuntu ships for other sandboxing tools (e.g. buildah), +# it confines nothing else: bubblewrap does the confining. + +abi , +include + +profile bwrap /usr/bin/bwrap flags=(unconfined) { + userns, + + # Site-specific additions and overrides. See local/README for details. + include if exists +} diff --git a/dev_tools/services/acknowledg-forge/deploy-remote.py b/dev_tools/services/acknowledg-forge/deploy-remote.py new file mode 100755 index 0000000000..d48fb581e6 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/deploy-remote.py @@ -0,0 +1,300 @@ +#!/usr/bin/python3 -I + +# 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. + +It uses only the standard library and runs isolated (python3 -I), so +nothing from the environment or the caller's directory can change it. +''' + +import argparse +import os +import pwd +import re +import subprocess +import sys +import tempfile +import time + +from pathlib import Path +from typing import NamedTuple +from urllib.parse import urlsplit, urlunsplit + +# The only repository images may come from. +IMAGE_REPOSITORY = 'ghcr.io/edgcpp/acknowledg-forge' +# The account the service runs as. +SERVICE_USER = 'acknowledg-forge' + +# Where root-owned Quadlet units for a user's services live, by uid. +QUADLET_USERS_DIR = Path('/etc/containers/systemd/users') +# systemd marks lingering users with a file here. +LINGER_DIR = Path('/var/lib/systemd/linger') +# The drop-in this script manages, in the unit's drop-in directory. +DROP_IN_NAME = '50-ci-deploy.conf' +# Seconds to give the service to fail on startup (bad configuration, +# missing access) before checking that it's still up. +STARTUP_GRACE_S = 5 + +_SERVICE_NAME = re.compile(r'acknowledg-forge-[a-z0-9-]+') +_IMAGE = re.compile(re.escape(IMAGE_REPOSITORY) + r'@sha256:[0-9a-f]{64}') +_HOST_NAME = re.compile(r'[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?') +_PORT = re.compile(r'[0-9]{1,5}') + +TLS_PORT = 443 + + +class DeployError(Exception): + '''A deployment that can't go ahead; the message says why.''' + + +class Endpoint(NamedTuple): + host: str + port: int + + +def parse_url(url: str, *, plain: str, tls: str, label: str) -> Endpoint: + '''The host and port of ``url``, which must use the ``plain`` or ``tls`` + scheme (e.g. ws and wss), a host name, an optional port, and no path. + + 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. + ''' + shape_error = DeployError( + f"the AcknowlEDG {label} must be {plain}:// or {tls}:// followed by a " + f"host name and optional port, with no path" + ) + # urlsplit quietly drops some characters (tabs, newlines) and empty + # parts ('?', '#'); anything it wouldn't reproduce exactly is refused. + parts = urlsplit(url) + if (url != urlunsplit(parts) or + parts.scheme not in (plain, tls) or + parts.path not in ('', '/') or + parts.query != '' or + parts.fragment != ''): + raise shape_error + host, colon, port_text = parts.netloc.partition(':') + if _HOST_NAME.fullmatch(host) is None: + raise shape_error + if colon != '' and _PORT.fullmatch(port_text) is None: + raise shape_error + + is_tls = parts.scheme == tls + if colon == '': + port = TLS_PORT if is_tls else 80 + else: + port = int(port_text) + if not 1 <= port <= 65535: + raise DeployError(f"the AcknowlEDG {label} has an invalid port") + if is_tls and port != TLS_PORT: + raise DeployError( + f"the AcknowlEDG {label} can only use {tls}:// on port {TLS_PORT}" + ) + if not is_tls and port == TLS_PORT: + raise DeployError( + f"the AcknowlEDG {label} must use {tls}:// on port {TLS_PORT}" + ) + return Endpoint(host, port) + + +def check_service(service: str) -> None: + if _SERVICE_NAME.fullmatch(service) is None: + raise DeployError('the service must be named acknowledg-forge-') + + +def check_image(image: str) -> None: + if _IMAGE.fullmatch(image) is None: + raise DeployError( + f"the image must be {IMAGE_REPOSITORY}, pinned by digest" + ) + + +def drop_in_content(image: str, api: Endpoint, ui: Endpoint) -> str: + '''The drop-in pointing the unit at ``image`` and the AcknowlEDG + endpoints. + ''' + return ( + '# Managed by the acknowledg-forge-deploy workflow; changes are ' + 'overwritten.\n' + '[Container]\n' + f"Image={image}\n" + f"Environment=EDG_ACKNOWLEDG_API_HOST={api.host}\n" + f"Environment=EDG_ACKNOWLEDG_API_PORT={api.port}\n" + f"Environment=EDG_ACKNOWLEDG_UI_HOST={ui.host}\n" + f"Environment=EDG_ACKNOWLEDG_UI_PORT={ui.port}\n" + ) + + +def write_if_changed(path: Path, content: str) -> bool: + '''Atomically replace ``path`` with ``content`` (mode 0644, root-owned, + so the service's account can't change what it runs), unless it already + holds exactly that; return whether it changed. + ''' + data = content.encode() + try: + if path.read_bytes() == data: + return False + except FileNotFoundError: + pass + path.parent.mkdir(mode = 0o755, exist_ok = True) + path.parent.chmod(0o755) + descriptor, temporary = tempfile.mkstemp( + dir = path.parent, + prefix = f".{path.name}." + ) + try: + with os.fdopen(descriptor, 'wb') as handle: + handle.write(data) + os.chmod(temporary, 0o644) + os.replace(temporary, path) + except BaseException: + os.unlink(temporary) + raise + return True + + +def deploy(service: str, image: str, api_url: str, ui_url: str) -> None: + if os.geteuid() != 0: + raise DeployError('must run as root (via sudo)') + check_service(service) + check_image(image) + api = parse_url(api_url, plain = 'ws', tls = 'wss', label = 'API URL') + ui = parse_url(ui_url, plain = 'http', tls = 'https', label = 'UI URL') + + try: + account = pwd.getpwnam(SERVICE_USER) + except KeyError: + raise DeployError(f"no {SERVICE_USER} account") from None + if not (LINGER_DIR / SERVICE_USER).exists(): + raise DeployError( + f"lingering is off for {SERVICE_USER} " + f"(loginctl enable-linger {SERVICE_USER})" + ) + unit_dir = QUADLET_USERS_DIR / str(account.pw_uid) + unit = unit_dir / f"{service}.container" + if not unit.is_file(): + raise DeployError( + f"{unit} is missing; set the service up first (see README.md)" + ) + + drop_in = unit_dir / f"{service}.container.d" / DROP_IN_NAME + changed = write_if_changed(drop_in, drop_in_content(image, api, ui)) + + # Commands run as the service's account, in its user session (and its + # home, since it may not be able to enter ours), and its user services. + as_service_user = [ + 'runuser', '-u', SERVICE_USER, '--', + 'env', f"XDG_RUNTIME_DIR=/run/user/{account.pw_uid}" + ] + systemctl = ['systemctl', '--user', f"--machine={SERVICE_USER}@"] + service_unit = f"{service}.service" + is_active = [*systemctl, 'is-active', '--quiet', service_unit] + + digest = image.partition('@')[2] + print(f"Pulling {digest}.", flush = True) + subprocess.run( + [*as_service_user, 'podman', 'pull', '--quiet', image], + cwd = account.pw_dir, + stdout = subprocess.DEVNULL, + check = True + ) + + subprocess.run([*systemctl, 'daemon-reload'], check = True) + if changed: + print(f"Restarting {service}.", flush = True) + subprocess.run([*systemctl, 'restart', service_unit], check = True) + elif subprocess.run(is_active).returncode != 0: + print(f"Starting {service}.", flush = True) + subprocess.run([*systemctl, 'start', service_unit], check = True) + else: + print(f"Unchanged; {service} keeps running.", flush = True) + + time.sleep(STARTUP_GRACE_S) + if subprocess.run(is_active).returncode != 0: + # Show why, if the journal can say. + subprocess.run( + [ + 'journalctl', f"_UID={account.pw_uid}", '--user-unit', service_unit, + '--lines', '30', '--no-pager' + ], + stdout = sys.stderr + ) + raise DeployError(f"{service} is not active") + + subprocess.run( + [*as_service_user, 'podman', 'image', 'prune', '--force'], + cwd = account.pw_dir, + stdout = subprocess.DEVNULL, + check = True + ) + print(f"Deployed {service} ({digest}).") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description = ( + 'Point an acknowledg-forge Quadlet service at a new image and restart ' + 'it (only if anything changed).' + ) + ) + parser.add_argument( + 'service', + help = 'the service (its unit), named acknowledg-forge-' + ) + parser.add_argument( + 'image', + help = f"the image, {IMAGE_REPOSITORY}@sha256:" + ) + parser.add_argument( + 'api_url', + metavar = 'acknowledg-api-url', + help = 'the public AcknowlEDG API, ws://[:] or wss://' + ) + parser.add_argument( + 'ui_url', + metavar = 'acknowledg-ui-url', + help = 'the public AcknowlEDG UI, http://[:] or ' + 'https://' + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + deploy(args.service, args.image, args.api_url, args.ui_url) + except DeployError as ex: + print(f"error: {ex}", file = sys.stderr) + return 1 + except subprocess.CalledProcessError as ex: + print(f"error: {' '.join(map(str, ex.cmd))} failed ({ex.returncode})", + file = sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/dev_tools/services/acknowledg-forge/deploy-remote.sh b/dev_tools/services/acknowledg-forge/deploy-remote.sh deleted file mode 100644 index d1ecab375b..0000000000 --- a/dev_tools/services/acknowledg-forge/deploy-remote.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/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/tests/test_deploy.py b/dev_tools/services/acknowledg-forge/tests/test_deploy.py new file mode 100644 index 0000000000..ab0fe93cd5 --- /dev/null +++ b/dev_tools/services/acknowledg-forge/tests/test_deploy.py @@ -0,0 +1,149 @@ +# 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 deploy helper (deploy-remote.py): it runs as root on a caller's +behalf, so its argument checks matter most. +''' + +import importlib.util +import os +import stat +import sys + +from pathlib import Path +from types import ModuleType + +import pytest + + +def _load_deploy() -> ModuleType: + path = Path(__file__).parent.parent / 'deploy-remote.py' + spec = importlib.util.spec_from_file_location('deploy_remote', path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules['deploy_remote'] = module + spec.loader.exec_module(module) + return module + + +deploy = _load_deploy() + +DIGEST = 'sha256:' + '0123456789abcdef' * 4 +IMAGE = f"ghcr.io/edgcpp/acknowledg-forge@{DIGEST}" + + +@pytest.mark.parametrize('url, expected', [ + ('wss://api.example', ('api.example', 443)), + ('wss://api.example/', ('api.example', 443)), + ('wss://api.example:443', ('api.example', 443)), + ('ws://localhost', ('localhost', 80)), + ('ws://10.0.0.1:8080', ('10.0.0.1', 8080)), + ('ws://api.example:0080', ('api.example', 80)), + ('ws://a', ('a', 80)), +]) +def test_urls_accepted(url: str, expected: tuple[str, int]) -> None: + assert deploy.parse_url( + url, plain = 'ws', tls = 'wss', label = 'API URL' + ) == expected + + +@pytest.mark.parametrize('url', [ + 'https://api.example', + 'WSS://api.example', + 'wss://', + 'wss://api.example/path', + 'wss://api.example?', + 'wss://api.example?x=1', + 'wss://api.example#', + 'wss://user@api.example', + 'wss://api.example:', + 'wss://api.example:+80', + 'wss://api.example:123456', + 'wss://-api.example', + 'wss://api.example.', + 'wss://[::1]', + 'wss://api.exa mple', + 'wss://api.exa\nmple', + 'wss://api.example\n', + 'wss://api.example\tEnvironment=X', + ' wss://api.example', +]) +def test_urls_refused(url: str) -> None: + with pytest.raises(deploy.DeployError, match = 'host name and optional'): + deploy.parse_url(url, plain = 'ws', tls = 'wss', label = 'API URL') + + +@pytest.mark.parametrize('url, message', [ + ('ws://api.example:0', 'invalid port'), + ('ws://api.example:65536', 'invalid port'), + ('wss://api.example:8443', 'only use wss:// on port 443'), + ('ws://api.example:443', 'must use wss:// on port 443'), +]) +def test_ports_refused(url: str, message: str) -> None: + with pytest.raises(deploy.DeployError, match = message): + deploy.parse_url(url, plain = 'ws', tls = 'wss', label = 'API URL') + + +def test_service_and_image_names() -> None: + deploy.check_service('acknowledg-forge-github') + for service in ('acknowledg-forge-', 'acknowledg-forge-GitHub', + 'other-github', 'acknowledg-forge-a/b', + 'acknowledg-forge-a\n'): + with pytest.raises(deploy.DeployError): + deploy.check_service(service) + + deploy.check_image(IMAGE) + for image in ( + 'ghcr.io/edgcpp/acknowledg-forge:latest', + f"ghcr.io/other/acknowledg-forge@{DIGEST}", + f"ghcrXio/edgcpp/acknowledg-forge@{DIGEST}", + f"{IMAGE}0", + f"{IMAGE}\n", + IMAGE.upper(), + ): + with pytest.raises(deploy.DeployError): + deploy.check_image(image) + + +def test_drop_in(tmp_path: Path) -> None: + content = deploy.drop_in_content( + IMAGE, + deploy.Endpoint('api.example', 443), + deploy.Endpoint('ui.example', 8080) + ) + assert content.splitlines()[1:] == [ + '[Container]', + f"Image={IMAGE}", + 'Environment=EDG_ACKNOWLEDG_API_HOST=api.example', + 'Environment=EDG_ACKNOWLEDG_API_PORT=443', + 'Environment=EDG_ACKNOWLEDG_UI_HOST=ui.example', + 'Environment=EDG_ACKNOWLEDG_UI_PORT=8080', + ] + + drop_in = tmp_path / 'acknowledg-forge-github.container.d' / 'deploy.conf' + assert deploy.write_if_changed(drop_in, content) + assert drop_in.read_text() == content + assert stat.S_IMODE(drop_in.stat().st_mode) == 0o644 + assert stat.S_IMODE(drop_in.parent.stat().st_mode) == 0o755 + assert not deploy.write_if_changed(drop_in, content) + assert deploy.write_if_changed(drop_in, content + '\n') + # Nothing is left behind. + assert os.listdir(drop_in.parent) == ['deploy.conf'] + + +def test_main_checks_usage_and_root(capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, 'argv', ['deploy', 'acknowledg-forge-github']) + with pytest.raises(SystemExit) as exit_info: + deploy.main() + assert exit_info.value.code == 2 + assert 'usage:' in capsys.readouterr().err + if os.geteuid() != 0: + monkeypatch.setattr(sys, 'argv', [ + 'deploy', 'acknowledg-forge-github', IMAGE, 'wss://api.example', + 'https://ui.example' + ]) + assert deploy.main() == 1 + assert 'must run as root' in capsys.readouterr().err diff --git a/dev_tools/services/acknowledg-forge/tests/test_github.py b/dev_tools/services/acknowledg-forge/tests/test_github.py index fa6eda40cf..3c333825a7 100644 --- a/dev_tools/services/acknowledg-forge/tests/test_github.py +++ b/dev_tools/services/acknowledg-forge/tests/test_github.py @@ -9,6 +9,7 @@ import hmac import json +from http import HTTPStatus from pathlib import Path from typing import Any, Callable @@ -71,21 +72,21 @@ 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(HTTPStatus.OK, json = [_pull(2)]) return httpx.Response( - 200, + HTTPStatus.OK, 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( + return httpx.Response(HTTPStatus.OK, 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) + return httpx.Response(HTTPStatus.OK, json = _pull(4, state = 'closed')) + return httpx.Response(HTTPStatus.NOT_FOUND) forge = GitHubForge(_client(handler), workflow = 'ci.yml') pulls = await forge.list_open_pull_requests(REPO) @@ -109,18 +110,18 @@ def handler(request: httpx.Request) -> httpx.Response: 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': [{ + return httpx.Response(HTTPStatus.OK, 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': [{ + return httpx.Response(HTTPStatus.OK, json = {'artifacts': [{ 'id': 5, 'name': 'test-results-all', 'size_in_bytes': 10, 'digest': 'sha256:abc', 'expired': False, }]}) - return httpx.Response(404) + return httpx.Response(HTTPStatus.NOT_FOUND) forge = GitHubForge(_client(handler), workflow = 'ci.yml') pull = forge._pull_request(REPO, _pull(1)) @@ -140,11 +141,11 @@ 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, + HTTPStatus.FOUND, 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) + return httpx.Response(HTTPStatus.OK, content = b'PK' + b'0' * 100) forge = GitHubForge(_client(handler), workflow = 'ci.yml') artifact = ArtifactInfo('test-results-all', '5', 102, None, False) @@ -175,8 +176,11 @@ 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)) + return httpx.Response( + HTTPStatus.FORBIDDEN, + headers = {'retry-after': '7'} + ) + return httpx.Response(HTTPStatus.OK, json = _pull(1)) forge = GitHubForge(_client(handler), workflow = None) assert (await forge.get_pull_request(REPO, 1)).number == 1 @@ -215,16 +219,16 @@ async def test_commenter_finds_only_its_own_marked_comment() -> None: def handler(request: httpx.Request) -> httpx.Response: path = request.url.path if path == '/user': - return httpx.Response(200, json = {'login': 'acknowledg-bot'}) + return httpx.Response(HTTPStatus.OK, 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 = [ + return httpx.Response(HTTPStatus.CREATED, json = {'id': 42}) + return httpx.Response(HTTPStatus.OK, json = [ {'id': 1, 'user': {'login': 'someone'}, 'body': 'MARK'}, {'id': 2, 'user': {'login': 'acknowledg-bot'}, 'body': 'x MARK y'}, ]) - return httpx.Response(404) + return httpx.Response(HTTPStatus.NOT_FOUND) commenter = GitHubCommenter(_client(handler)) assert await commenter.post_comment(REPO, 1, 'hello') == '42' diff --git a/dev_tools/services/acknowledg-forge/tests/test_gitlab.py b/dev_tools/services/acknowledg-forge/tests/test_gitlab.py index aa823e4480..fd7393aa60 100644 --- a/dev_tools/services/acknowledg-forge/tests/test_gitlab.py +++ b/dev_tools/services/acknowledg-forge/tests/test_gitlab.py @@ -8,6 +8,7 @@ import json from datetime import datetime, timezone +from http import HTTPStatus from pathlib import Path from typing import Any, Callable @@ -76,12 +77,12 @@ def handler(request: httpx.Request) -> httpx.Response: 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)]) + return httpx.Response(HTTPStatus.OK, json = [_merge_request(1)]) if path == f"/api/v4/projects/{ENCODED}/merge_requests/2": - return httpx.Response(200, json = _merge_request(2, 'merged')) + return httpx.Response(HTTPStatus.OK, 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) + return httpx.Response(HTTPStatus.OK, json = _merge_request(3, 'locked')) + return httpx.Response(HTTPStatus.NOT_FOUND) forge = _forge(handler) [first] = await forge.list_open_pull_requests(PROJECT) @@ -103,13 +104,13 @@ def handler(request: httpx.Request) -> httpx.Response: 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 = [{ + return httpx.Response(HTTPStatus.OK, 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 = [ + return httpx.Response(HTTPStatus.OK, json = [ {'id': 1, 'name': 'build', 'artifacts': [ {'file_type': 'archive', 'size': 5}, ]}, @@ -123,7 +124,7 @@ def handler(request: httpx.Request) -> httpx.Response: {'file_type': 'archive', 'size': 20}, ]}, ]) - return httpx.Response(404) + return httpx.Response(HTTPStatus.NOT_FOUND) forge = _forge(handler) pull = forge._merge_request(PROJECT, _merge_request(1)) @@ -144,7 +145,7 @@ def handler(request: httpx.Request) -> httpx.Response: 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 = [{ + return httpx.Response(HTTPStatus.OK, json = [{ 'id': 901, 'sha': 'h' * 40, 'status': 'running', 'updated_at': '2026-10-01T13:00:00Z', 'web_url': 'u', }]) @@ -161,7 +162,7 @@ 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') + return httpx.Response(HTTPStatus.OK, content = b'PK-zip') forge = _forge(handler) destination = tmp_path / 'a.zip' @@ -203,16 +204,19 @@ 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'}) + return httpx.Response( + HTTPStatus.OK, + 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 = [ + return httpx.Response(HTTPStatus.CREATED, json = {'id': 7}) + return httpx.Response(HTTPStatus.OK, json = [ {'id': 5, 'author': {'username': 'other'}, 'body': 'MARK'}, {'id': 6, 'author': {'username': 'acknowledg-bot'}, 'body': 'MARK'}, ]) - return httpx.Response(404) + return httpx.Response(HTTPStatus.NOT_FOUND) client = GitLabClient( api_url = API, diff --git a/dev_tools/services/acknowledg-forge/tests/test_publisher_worker.py b/dev_tools/services/acknowledg-forge/tests/test_publisher_worker.py index 3c620136e0..4c1d5567d1 100644 --- a/dev_tools/services/acknowledg-forge/tests/test_publisher_worker.py +++ b/dev_tools/services/acknowledg-forge/tests/test_publisher_worker.py @@ -9,12 +9,13 @@ import asyncio import shutil +import socket import subprocess import sys import uuid from pathlib import Path -from typing import Any +from typing import Any, cast import pytest import websockets @@ -23,6 +24,7 @@ from edgacknowledg.forge.db import SectionRecord from edgacknowledg.forge.publisher import ( KIND_TEST_CHANGES, + PublisherUnavailableError, ReviewIdentity, SubprocessPublisher, ) @@ -142,8 +144,8 @@ async def test_worker_sees_only_reviews(tmp_path: Path) -> None: reviews_dir = reviews, home = home ) - command = publisher._worker_command() - # The API key goes over stdin, not the command line or environment. + command = publisher._worker_command(3) + # The API key goes over the socket, not the command line or environment. assert API_KEY not in ' '.join(command) # The worker's sandbox, running a probe instead of the worker. @@ -169,3 +171,50 @@ async def test_worker_sees_only_reviews(tmp_path: Path) -> None: assert 'clone' not in probe.stdout assert 'forge-token-value' not in probe.stdout assert 'WROTE' not in probe.stdout + + +class _SilentWorker: + '''Stands in for a worker process that never answers.''' + + returncode = None + + def __init__(self) -> None: + self.killed = False + + def kill(self) -> None: + self.killed = True + + +async def test_unanswered_request_detaches_the_worker(tmp_path: Path) -> None: + home, reviews, section = _layout(tmp_path) + publisher = SubprocessPublisher( + 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, + home = home, + request_timeout = 0.1 + ) + parent_end, worker_end = socket.socketpair() + _, channel = await asyncio.open_unix_connection(sock = parent_end) + worker = _SilentWorker() + publisher._process = cast(asyncio.subprocess.Process, worker) + publisher._channel = channel + publisher._connected.set() + review = ReviewIdentity(uuid.uuid4(), 'edg/compiler#1: test', '') + try: + with pytest.raises(PublisherUnavailableError, match = 'stopped answering'): + await publisher.sync(review, [section]) + assert worker.killed + + # Its late answer isn't taken for the next request's: nothing more is + # sent to it. + publisher._handle_message(b'{"ok": true}\n') + with pytest.raises(PublisherUnavailableError, match = 'not running'): + await publisher.sync(review, [section]) + finally: + channel.close() + worker_end.close() diff --git a/dev_tools/services/acknowledg-forge/tests/test_safety.py b/dev_tools/services/acknowledg-forge/tests/test_safety.py index ac1a73c9df..c407a8c3cf 100644 --- a/dev_tools/services/acknowledg-forge/tests/test_safety.py +++ b/dev_tools/services/acknowledg-forge/tests/test_safety.py @@ -16,7 +16,11 @@ import pytest -from edgacknowledg.forge.sandbox import Sandbox, SandboxUnavailableError +from edgacknowledg.forge.sandbox import ( + TMPFS_SIZE, + Sandbox, + SandboxUnavailableError, +) from edgacknowledg.forge.workspace import ( UnsafeArchiveError, WorkspaceError, @@ -244,3 +248,15 @@ async def test_sandboxed_commands_cannot_create_namespaces() -> None: timeout = 30 ) assert 'UNSHARED' not in result.output, result.output + + +@needs_bwrap +async def test_sandbox_tmp_is_bounded() -> None: + sandbox = Sandbox(enabled = True) + await sandbox.check() + result = await sandbox.run( + ['df', '--block-size=1', '--output=size', '/tmp'], + timeout = 30 + ) + assert result.succeeded, result.output + assert int(result.output.split()[-1]) == TMPFS_SIZE diff --git a/dev_tools/services/acknowledg-forge/tests/test_state.py b/dev_tools/services/acknowledg-forge/tests/test_state.py index 25a0c68d7e..067128f085 100644 --- a/dev_tools/services/acknowledg-forge/tests/test_state.py +++ b/dev_tools/services/acknowledg-forge/tests/test_state.py @@ -10,6 +10,7 @@ import json import zipfile +from http import HTTPStatus from pathlib import Path from edgacknowledg.forge import ( @@ -246,18 +247,18 @@ async def test_webhook_endpoint_verifies_and_queues() -> None: response = await client.post( '/webhook', data = body, headers = {'X-Signature': 'bogus'} ) - assert response.status_code == 401 + assert response.status_code == HTTPStatus.UNAUTHORIZED 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 response.status_code == HTTPStatus.ACCEPTED assert queue._queue.get_nowait() == PullRequestTouched(REPOSITORY, 3) response = await client.get('/healthz') - assert response.status_code == 200 + assert response.status_code == HTTPStatus.OK async def test_bench_sections_from_ci_artifact(tmp_path: Path, diff --git a/dev_tools/services/acknowledg-forge/tests/test_symlinks.py b/dev_tools/services/acknowledg-forge/tests/test_symlinks.py index 296b617c55..2bcd41f373 100644 --- a/dev_tools/services/acknowledg-forge/tests/test_symlinks.py +++ b/dev_tools/services/acknowledg-forge/tests/test_symlinks.py @@ -7,8 +7,11 @@ and sandbox output must never let reviewers read outside the review. ''' +import io +import json import os import shutil +import tarfile import uuid from pathlib import Path, PurePosixPath @@ -24,10 +27,13 @@ resolve_baseline_dir, ) from edgacknowledg.client.shared import ( + DEFAULT_MAX_FILE_SIZE, ConfinedPathComponent, + FileTooLargeError, SandboxContext, ) # (Not 'from ... import TestSourceHandler', which pytest would collect.) +import edgacknowledg.client.shared as shared import edgacknowledg.client.test as test_provider from edgacknowledg.client.test import ( @@ -35,6 +41,7 @@ _test_if_any_output_exists, ) from edgacknowledg.forge.db import ForgeStore, SectionRecord +from edgtest.runtest import RunTestOutput, RunTestOutputLinkError from edgacknowledg.forge.publisher import ( KIND_BENCH, KIND_TEST_FAILURES, @@ -467,12 +474,88 @@ def test_bench_reads_are_confined(tmp_path: Path) -> None: assert read_program_totals(trusted / 'x.bnch.cgout') == 1234 with pytest.raises(IllegalFileAccessError): read_program_totals(trusted / 'y.bnch.cgout') + # Collecting fails outright on any file it may not read. + with pytest.raises(IllegalFileAccessError): + collect_all_benchmarks(SandboxContext(run).subpath(run), trusted) + (baseline / 'y.bnch.cgout').unlink() 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) - ) == [] + with pytest.raises(IllegalFileAccessError): + collect_all_benchmarks( + SandboxContext(run, allow_symlinks = False).subpath(run), + SandboxContext(baseline, allow_symlinks = False).subpath(baseline) + ) + + +def test_file_size_limit(tmp_path: Path) -> None: + (tmp_path / 'fits').write_text('0123456789') + (tmp_path / 'too-large').write_text('0123456789A') + for allow_symlinks in (True, False): + context = SandboxContext( + tmp_path, + allow_symlinks = allow_symlinks, + max_file_size = 10 + ) + # The limit carries over to derived paths. + root = context.subpath(tmp_path) + assert (root / 'fits').read_text() == '0123456789' + with pytest.raises(FileTooLargeError): + (root / 'too-large').read_bytes() + unlimited = SandboxContext( + tmp_path, + allow_symlinks = allow_symlinks, + max_file_size = None + ) + assert unlimited.subpath(tmp_path / 'too-large').read_text() == ( + '0123456789A' + ) + assert SandboxContext(tmp_path).max_file_size == DEFAULT_MAX_FILE_SIZE + + +async def test_show_source_reads_only_what_is_shown( + tmp_path: Path, + secret: Path, + monkeypatch: pytest.MonkeyPatch) -> None: + home, root = _test_tree(tmp_path, secret) + (home / 'tests' / 'suite' / 'big.sft.cpp').write_text('int x;\n' * 1000) + monkeypatch.setattr(test_provider, 'MAX_DISPLAY_CONTENT_SIZE', 64) + monkeypatch.setattr(shared, 'MAX_DISPLAY_CONTENT_SIZE', 64) + reads: list[int] = [] + real_open = shared.ConfinedPath.open + + def open_recording_reads(self: shared.ConfinedPath, + *args: Any, + **kwargs: Any) -> Any: + handle = real_open(self, *args, **kwargs) + real_read = handle.read + + def read(size: int = -1) -> Any: + reads.append(size) + return real_read(size) + + handle.read = read + return handle + + monkeypatch.setattr(shared.ConfinedPath, 'open', open_recording_reads) + context = SandboxContext(home, allow_symlinks = False) + handler = test_provider.TestSourceHandler( + 'Test', + 'test', + context, + context.subpath(home), + context.subpath(root) + ) + channel = FakeChannel(message( + RequestTags.TEST_SHOW_SOURCE, + value = {'id': 'suite/big.sft.cpp', 'assoc_file': None, + 'reviewer_id': 'r'} + )) + await handler.handle_request(channel) # type: ignore[arg-type] + [reply] = channel.sent + response = reply['v']['client_response'] + assert response['truncated'] and len(response['content']) <= 64 + assert response['content'].startswith('int x;\n') + assert reads == [65] def test_sandbox_context_commands(tmp_path: Path) -> None: @@ -484,7 +567,7 @@ def test_sandbox_context_commands(tmp_path: Path) -> None: command_timeout = 5 ) file = context.subpath(tmp_path / 'home' / 'file') - assert context.command('tool', '--flag', file) == [ + assert context.command('tool', '--flag', str(file)) == [ 'bwrap', '--', 'tool', '--flag', str(tmp_path / 'home' / 'file') ] local = SandboxContext(tmp_path) @@ -619,3 +702,52 @@ async def test_test_run_outside_home(tmp_path: Path, secret: Path) -> None: context.subpath(run) ) assert await _show_source(handler, 'suite/a.sft.cpp') == 'int a;\n' + + +def _rt_with_links(path: Path, *, link_metadata: bool) -> None: + '''A run artifact whose command output is a symlink, the matches a hard + link, and (optionally) metadata.json a link too. + ''' + metadata = json.dumps({ + 'cases': [{'commands': [{'command': 'cc'}], 'matches': {}}] + }).encode() + with tarfile.open(path, 'w:gz') as archive: + member = tarfile.TarInfo('real.json') + member.size = len(metadata) + archive.addfile(member, io.BytesIO(metadata)) + for name, link_type, target in [ + ('metadata.json', tarfile.LNKTYPE if link_metadata else None, + 'real.json'), + ('cases/1/1/output.txt', tarfile.SYMTYPE, '../../../real.json'), + ('cases/1/matches/output.txt', tarfile.LNKTYPE, 'real.json'), + ]: + member = tarfile.TarInfo(name) + if link_type is None: + member.size = len(metadata) + archive.addfile(member, io.BytesIO(metadata)) + continue + member.type = link_type + member.linkname = target + archive.addfile(member) + + +def test_run_output_refuses_link_members(tmp_path: Path) -> None: + rt = tmp_path / 'a.rt.tar.gz' + _rt_with_links(rt, link_metadata = False) + with RunTestOutput(rt) as output: + assert output.metadata['cases'][0]['commands'] == [{'command': 'cc'}] + with pytest.raises(RunTestOutputLinkError): + output.command_output(1, 1) + with pytest.raises(RunTestOutputLinkError): + output.extract_command_output(1, 1, tmp_path / 'out') + with pytest.raises(RunTestOutputLinkError): + output.extract_case_matches(1, tmp_path / 'matches') + with pytest.raises(RunTestOutputLinkError): + output.combined_raw_output() + with pytest.raises(FileNotFoundError): + output.command_output(1, 2) + + _rt_with_links(rt, link_metadata = True) + with RunTestOutput(rt) as output: + with pytest.raises(RunTestOutputLinkError): + output.metadata diff --git a/dev_tools/services/acknowledg-forge/typecheck.sh b/dev_tools/services/acknowledg-forge/typecheck.sh index 424b1ab910..7278dc9f24 100755 --- a/dev_tools/services/acknowledg-forge/typecheck.sh +++ b/dev_tools/services/acknowledg-forge/typecheck.sh @@ -22,4 +22,5 @@ exec uv run --project github --with mypy --with-editable gitlab \ --follow-imports=silent \ "$(realpath ../../pylibs/edgacknowledg/forge)" \ "$(realpath github/acknowledg_forge_github)" \ - "$(realpath gitlab/acknowledg_forge_gitlab)" + "$(realpath gitlab/acknowledg_forge_gitlab)" \ + "$(realpath deploy-remote.py)" From db2ad46c1c5ba24454e6c35b8d280493df575ba6 Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Mon, 19 Oct 2026 22:48:15 +0000 Subject: [PATCH 7/9] Improve sandboxing docs [] --- .../services/acknowledg-forge/SANDBOXING.md | 245 ++++++++++++++---- 1 file changed, 198 insertions(+), 47 deletions(-) diff --git a/dev_tools/services/acknowledg-forge/SANDBOXING.md b/dev_tools/services/acknowledg-forge/SANDBOXING.md index 72acf4e755..e5cd7b779c 100644 --- a/dev_tools/services/acknowledg-forge/SANDBOXING.md +++ b/dev_tools/services/acknowledg-forge/SANDBOXING.md @@ -54,57 +54,98 @@ flowchart TB clones[(clones)] end - subgraph build [bubblewrap: build commands] + subgraph build [bubblewrap: build sandbox] rematerialize[edg-rematerialize-test-run, patch] end - subgraph worker [bubblewrap: publisher worker] + subgraph worker [bubblewrap: publisher worker sandbox] handlers[review section handlers] - subgraph leaf [bubblewrap: reviewer commands] + subgraph leaf [bubblewrap: reviewer command sandbox] tools[ifc-dump, cg_annotate] end end - reviews[(reviews/: worktrees and run directories)] + reviews[(reviews/:
worktrees and
run directories)] end forge <-->|HTTPS| service - service -->|starts: worktree read-only, staging writable| build + service -->|starts, per section| build service <-->|socket: sync and remove requests| worker worker <-->|WebSocket, API key| acknowledg - handlers -->|starts, with the section read-only| leaf - build -.->|writes staging| reviews - service -.->|checks out, extracts artifacts| reviews - worker -.->|reads, read-only| reviews + handlers -->|starts, per reviewer request| leaf + service -.->|checks out worktrees, symlinks as plain files;
extracts CI artifacts| reviews + build -.->|writes the rebuilt run into a new staging directory,
checked, then renamed into place| reviews + worker -.->|reads sections for reviewers,
through a read-only bind| reviews + leaf -.->|reads one section's tree,
through a read-only bind| reviews ``` The service process is the only one holding the forge token, the webhook secret, the database, and the clones. It never reads pull request content beyond checking its shape; reading it is left to sandboxed processes. -### What each sandbox sees - -| | Build commands | Publisher worker | Reviewer commands | -| --- | --- | --- | --- | -| Started by | the service | the service | the publisher worker | -| Runs | `edg-rematerialize-test-run` (and `patch`) | `edgacknowledg.forge.publisher_worker` | `ifc-dump`, `cg_annotate` | -| Reads | `/usr`, the tools, the worktree | `/usr`, the tools, the Python installation, `reviews/` | `/usr`, the tools, the section's tree | -| Writes | its staging directory | nothing | nothing | -| `/tmp` | tmpfs, 512 MiB | tmpfs, 512 MiB | tmpfs, 512 MiB | -| Network | none | yes (AcknowlEDG), with DNS and TLS configuration from `/etc` | none | -| Environment | cleared, but for what it needs (`PATH`, `HOME`, ...) | the same | the same | -| Capabilities | none | none | none | -| New user namespaces | refused (`--disable-userns`) | allowed, for its own sandboxes | refused (`--disable-userns`) | -| Time limit | `ACKNOWLEDG_FORGE_REMATERIALIZE_TIMEOUT` (15 min) | 15 min per request, else restarted | 2 min | - -Every sandbox also gets fresh PID, IPC, UTS, and cgroup namespaces, its own -`/proc` and `/dev`, a new session (so it can't inject input into a -terminal), and dies with its parent. +## What each sandbox sees + +All three sandboxes get: + +- fresh user, PID, IPC, UTS, and cgroup namespaces, and their own `/proc` + and `/dev`; +- a read-only `/usr` (with the host's `/bin` and `/lib*` layout) and the + frozen tools; +- a 512 MiB tmpfs `/tmp`; +- no capabilities, and an environment cleared but for what they need + (`PATH`, `HOME`, ...); +- a new session (so they can't inject input into a terminal), and death + with their parent. bubblewrap follows symlinks in the paths it binds, so the service binds only directories it created, or checked to be real directories (`require_real_directory`). +### Build sandbox + +It rebuilds a section's test run from the pull request's diff, so it needs +to read the worktree the diff applies to, and write the result. + +| Property | Value | +| --- | --- | +| Started by | the service, once per section built | +| Runs | `edg-rematerialize-test-run`, which runs `patch` | +| Reads | the review's worktree | +| Writes | only a new staging directory for the run | +| Network | none | +| New user namespaces | refused (`--disable-userns`) | +| Time limit | `ACKNOWLEDG_FORGE_REMATERIALIZE_TIMEOUT` (15 min) | + +### Publisher worker sandbox + +It answers reviewers, so it needs to reach AcknowlEDG and read every +served section, and to start reviewer command sandboxes of its own. + +| Property | Value | +| --- | --- | +| Started by | the service, once, and again whenever it exits | +| Runs | `edgacknowledg.forge.publisher_worker` | +| Reads | `reviews/`, and the Python installation the service runs on | +| Writes | nothing | +| Network | yes, for AcknowlEDG, with DNS and TLS configuration from `/etc` | +| New user namespaces | allowed, for reviewer command sandboxes | +| Time limit | none, but a request unanswered for 15 min restarts it | + +### Reviewer command sandbox + +It runs a tool over one file a reviewer asked about, so it needs only that +file's section. + +| Property | Value | +| --- | --- | +| Started by | the publisher worker, once per reviewer request needing a tool | +| Runs | `ifc-dump` (test sections), `cg_annotate` (bench sections) | +| Reads | the section's tree | +| Writes | nothing | +| Network | none | +| New user namespaces | refused (`--disable-userns`) | +| Time limit | 2 min | + ## Building a section ```mermaid @@ -112,7 +153,7 @@ sequenceDiagram autonumber participant F as Forge participant S as Service - participant B as bubblewrap: build + participant B as Build sandbox participant W as Publisher worker S->>F: fetch the pull request @@ -137,39 +178,157 @@ them. ## Serving reviewers +Reviewers' requests reach the publisher worker through AcknowlEDG. Most +are answered by the worker itself; those needing a tool are delegated to a +reviewer command sandbox. + +### Test source (answered in the worker) + ```mermaid sequenceDiagram participant R as Reviewer participant A as AcknowlEDG participant W as Publisher worker - participant C as Reviewer command
(bubblewrap) R->>A: show a test's source A->>W: request Note over W: ConfinedPath.open: no symlinks,
under the section, at most 64 MiB - W-->>A: the contents (only as much as can be displayed) + Note over W: read only as much
as can be displayed + W-->>A: the contents A-->>R: response +``` - R->>A: show an IFC dump +### IFC dump (delegated to a sandboxed command) + +```mermaid +sequenceDiagram + participant R as Reviewer + participant A as AcknowlEDG + participant W as Publisher worker + participant C as Reviewer command
sandbox + + R->>A: show a test's IFC dump A->>W: request + Note over W: ConfinedPath: the file
is under the section W->>C: ifc-dump path Note over C: section read-only, 2 min C-->>W: output - W-->>A: response + W-->>A: the output + A-->>R: response ``` +Bench sections' annotated profiles (`cg_annotate`) take the same path. + Sections are read-only: reviewers can't accept diffs or update baselines through the service. -### The publisher worker +## The publisher worker + +The worker runs `AcknowledgPublisher`, acknowledg-forge's AcknowlEDG +client. It holds one batch connection to AcknowlEDG and serves each +review's sections with the stock providers from `edgacknowledg.client` +(`TestSourceHandler`, `BenchReviewSession`), the same handlers that +`edgy-review` and `edg-bench-review` use. The service drives it through +`SubprocessPublisher`, which implements the same `Publisher` interface, so +the rest of the service doesn't know the worker is a separate process. -Answering reviewers means reading pull request content, so it happens in a -separate process, `publisher_worker`, rather than in the service. The worker -wraps the same `AcknowledgPublisher` that a local review uses; the service -drives it through `SubprocessPublisher`. +### High-level loop -They talk over a Unix socket pair: the service keeps one end and passes the -other to the worker (its file descriptor is the worker's only argument). +The service runs these tasks concurrently (`forge/service.py`): + +```mermaid +flowchart LR + subgraph service [Service process] + poller[Poller:
every poll interval] + webhooks[Webhook receiver:
optional] + ingestor[Ingestor] + db[(database)] + processor[Processor:
review management loop] + announcer[Announcer:
optional comments] + subprocess[SubprocessPublisher:
worker respawn loop] + end + + subgraph sandbox [bubblewrap] + worker[Publisher worker] + end + + forge[(Forge API)] + acknowledg[(AcknowlEDG API)] + + poller -->|pull requests, CI runs| ingestor + webhooks -->|what changed| ingestor + ingestor -->|records, marks dirty| db + ingestor -->|wakes| processor + processor -->|builds sections from| db + processor -->|sync, remove| subprocess + processor -->|after each sweep| announcer + announcer -->|comments with section links| forge + subprocess <-->|socket| worker + worker <-->|serves sections| acknowledg +``` + +Polling and webhooks only say what may have changed; the ingestor asks the +forge for the actual state. + +### Review management loop + +The processor (`Processor.run`) decides what each review serves: + +```mermaid +flowchart TB + start([start]) --> connected1[wait until the worker
is connected to AcknowlEDG] + connected1 --> restore[restore: serve the stored sections again,
or mark their pull requests dirty if their files are gone] + restore --> connected2[wait until connected] + connected2 --> due[start processing every dirty pull request,
a few at a time] + due --> maintain{6 h since the
last maintenance?} + maintain -->|yes| gc[git housekeeping] --> sleep + maintain -->|no| sleep[wait to be woken,
or 60 s] + sleep --> connected2 + + due -.-> process + subgraph process [Processing one pull request] + open{open, against a
served branch?} + open -->|no| teardown[remove its review,
delete its worktrees] + open -->|yes| fetchbuild[fetch it, build its sections] + fetchbuild --> sync[sync the sections] + sync -->|ok| record[record the sections,
delete superseded runs] + sync -->|unavailable| defer[stays dirty:
retried once connected] + fetchbuild -->|error| retry[retried with backoff:
1 min up to 1 h,
at most 8 times] + sync -->|error| retry + end +``` + +### Worker respawn loop + +`SubprocessPublisher` keeps one worker running, and makes sure a new one +serves whatever the last one did: + +```mermaid +flowchart TB + spawn[create a socket pair;
start the worker in its sandbox
with one end] + spawn --> init[send the settings,
with the API key] + init --> read[read the worker's messages] + read -->|connected| replay{replay needed?} + replay -->|yes| resync[sync every review's
desired sections again] + resync --> ready + replay -->|no| ready[accept requests] + ready --> read + read -->|disconnected| notready[refuse requests:
unavailable] + notready --> read + read -->|request unanswered
for 15 min| kill[kill the worker,
send it nothing more] + kill --> exited + read -->|end of messages| exited[fail the request in flight
as unavailable;
replay needed] + exited --> backoff[wait 1 s, doubling
up to 60 s; back to 1 s
after a long run] + backoff --> spawn +``` + +At startup, the service checks that bubblewrap can run nested this way +(`Sandbox.check`), and refuses to start otherwise. + +### Messages + +The service and the worker talk over the socket pair: the worker's end is +its only argument (a file descriptor), and its commands don't inherit it. Messages are JSON objects, one per line: ```mermaid @@ -191,14 +350,6 @@ sequenceDiagram - Requests go one at a time, so each answer belongs to the request in flight. `unavailable` (not connected to AcknowlEDG) defers the pull request until the connection is back; any other error fails it. -- If the worker exits, or doesn't answer a request within 15 minutes (it is - then killed, and sent nothing more), the service restarts it, with a delay - doubling from 1 s up to 60 s. Once the new worker connects, the service - serves every review again before sending anything new. -- The worker's commands don't inherit its end of the socket. - -At startup, the service checks that bubblewrap can run nested this way -(`Sandbox.check`), and refuses to start otherwise. ## Symlinks and links From a7e497c1930b13fbaa0337f064e7297c422a80ea Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Mon, 19 Oct 2026 22:57:49 +0000 Subject: [PATCH 8/9] Improve sandboxing docs [] --- .../services/acknowledg-forge/SANDBOXING.md | 131 ++++++++++-------- 1 file changed, 72 insertions(+), 59 deletions(-) diff --git a/dev_tools/services/acknowledg-forge/SANDBOXING.md b/dev_tools/services/acknowledg-forge/SANDBOXING.md index e5cd7b779c..5825dde64b 100644 --- a/dev_tools/services/acknowledg-forge/SANDBOXING.md +++ b/dev_tools/services/acknowledg-forge/SANDBOXING.md @@ -49,17 +49,23 @@ flowchart TB subgraph container [Container: rootless Podman] subgraph service [Service process] - secrets[/forge token, webhook secret/] + poller[Poller:
every poll interval] + webhooks[Webhook receiver:
optional] + ingestor[Ingestor] + processor[Processor:
review management loop] + announcer[Announcer:
optional comments] + subprocess[SubprocessPublisher:
worker respawn loop] + secrets[/forge token,
webhook secret/] db[(database)] clones[(clones)] end subgraph build [bubblewrap: build sandbox] - rematerialize[edg-rematerialize-test-run, patch] + rematerialize[edg-rematerialize-test-run,
patch] end subgraph worker [bubblewrap: publisher worker sandbox] - handlers[review section handlers] + publisher[Publisher worker] subgraph leaf [bubblewrap: reviewer command sandbox] tools[ifc-dump, cg_annotate] end @@ -68,20 +74,32 @@ flowchart TB reviews[(reviews/:
worktrees and
run directories)] end - forge <-->|HTTPS| service - service -->|starts, per section| build - service <-->|socket: sync and remove requests| worker - worker <-->|WebSocket, API key| acknowledg - handlers -->|starts, per reviewer request| leaf - service -.->|checks out worktrees, symlinks as plain files;
extracts CI artifacts| reviews - build -.->|writes the rebuilt run into a new staging directory,
checked, then renamed into place| reviews - worker -.->|reads sections for reviewers,
through a read-only bind| reviews + forge -->|webhook deliveries| webhooks + poller -->|pull requests, CI runs| ingestor + webhooks -->|what changed| ingestor + ingestor -->|asks for the actual state| forge + ingestor -->|records, marks dirty| db + ingestor -->|wakes| processor + processor -->|fetches into| clones + processor -->|fetches pull requests,
downloads artifacts| forge + processor -.->|checks out worktrees, symlinks as plain files;
extracts CI artifacts| reviews + processor -->|starts, per section| build + build -.->|writes the rebuilt run to staging,
checked, then renamed into place| reviews + processor -->|sync, remove| subprocess + processor -->|after each sweep| announcer + announcer -->|comments with section links| forge + subprocess <-->|socket| publisher + publisher <-->|WebSocket, API key| acknowledg + publisher -.->|reads sections for reviewers,
through a read-only bind| reviews + publisher -->|starts, per reviewer request| leaf leaf -.->|reads one section's tree,
through a read-only bind| reviews ``` The service process is the only one holding the forge token, the webhook secret, the database, and the clones. It never reads pull request content -beyond checking its shape; reading it is left to sandboxed processes. +beyond checking its shape; reading it is left to sandboxed processes. The +processor's and `SubprocessPublisher`'s loops are described under +[The publisher worker](#the-publisher-worker). ## What each sandbox sees @@ -232,44 +250,6 @@ review's sections with the stock providers from `edgacknowledg.client` `SubprocessPublisher`, which implements the same `Publisher` interface, so the rest of the service doesn't know the worker is a separate process. -### High-level loop - -The service runs these tasks concurrently (`forge/service.py`): - -```mermaid -flowchart LR - subgraph service [Service process] - poller[Poller:
every poll interval] - webhooks[Webhook receiver:
optional] - ingestor[Ingestor] - db[(database)] - processor[Processor:
review management loop] - announcer[Announcer:
optional comments] - subprocess[SubprocessPublisher:
worker respawn loop] - end - - subgraph sandbox [bubblewrap] - worker[Publisher worker] - end - - forge[(Forge API)] - acknowledg[(AcknowlEDG API)] - - poller -->|pull requests, CI runs| ingestor - webhooks -->|what changed| ingestor - ingestor -->|records, marks dirty| db - ingestor -->|wakes| processor - processor -->|builds sections from| db - processor -->|sync, remove| subprocess - processor -->|after each sweep| announcer - announcer -->|comments with section links| forge - subprocess <-->|socket| worker - worker <-->|serves sections| acknowledg -``` - -Polling and webhooks only say what may have changed; the ingestor asks the -forge for the actual state. - ### Review management loop The processor (`Processor.run`) decides what each review serves: @@ -329,7 +309,12 @@ At startup, the service checks that bubblewrap can run nested this way The service and the worker talk over the socket pair: the worker's end is its only argument (a file descriptor), and its commands don't inherit it. -Messages are JSON objects, one per line: +Messages are JSON objects, one per line. + +#### Requests + +After the settings, the service sends a request each time a review's +sections change, and waits for its answer: ```mermaid sequenceDiagram @@ -337,19 +322,47 @@ sequenceDiagram participant W as Publisher worker S->>W: settings, including the AcknowlEDG API key - W-->>S: {"event": "connected"} - S->>W: {"op": "sync", "review": ..., "sections": [...]} - W-->>S: {"ok": true} - W-->>S: {"event": "disconnected"} - S->>W: {"op": "remove", "review_id": ...} - W-->>S: {"error": "unavailable", "message": ...} + loop each time a review's sections change + alt the review serves sections + S->>W: {"op": "sync", "review": ..., "sections": [...]} + else the review is gone + S->>W: {"op": "remove", "review_id": ...} + end + alt served + W-->>S: {"ok": true} + else not connected to AcknowlEDG + W-->>S: {"error": "unavailable", "message": ...} + else failed + W-->>S: {"error": "failed", "message": ...} + end + end ``` - The API key goes over the socket, not the command line or environment, where `/proc` would show it. - Requests go one at a time, so each answer belongs to the request in - flight. `unavailable` (not connected to AcknowlEDG) defers the pull - request until the connection is back; any other error fails it. + flight. `unavailable` defers the pull request until the connection is + back; `failed` fails it. + +#### Connection events + +Separately, and only when its AcknowlEDG connection changes, the worker +reports it, at any time between answers: + +```mermaid +sequenceDiagram + participant S as Service
(SubprocessPublisher) + participant W as Publisher worker + + opt the worker connects to AcknowlEDG + W-->>S: {"event": "connected"} + Note over S: replay sections if needed,
then accept requests + end + opt the worker loses AcknowlEDG + W-->>S: {"event": "disconnected"} + Note over S: refuse requests as unavailable
until it reconnects + end +``` ## Symlinks and links From a5c329dd3413be27660151465c80d74de8a562fe Mon Sep 17 00:00:00 2001 From: Wyatt Childers Date: Mon, 19 Oct 2026 23:04:40 +0000 Subject: [PATCH 9/9] Improve sandboxing docs [] --- .../services/acknowledg-forge/SANDBOXING.md | 68 +++++++++++-------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/dev_tools/services/acknowledg-forge/SANDBOXING.md b/dev_tools/services/acknowledg-forge/SANDBOXING.md index 5825dde64b..3074f78a2d 100644 --- a/dev_tools/services/acknowledg-forge/SANDBOXING.md +++ b/dev_tools/services/acknowledg-forge/SANDBOXING.md @@ -98,7 +98,8 @@ flowchart TB The service process is the only one holding the forge token, the webhook secret, the database, and the clones. It never reads pull request content beyond checking its shape; reading it is left to sandboxed processes. The -processor's and `SubprocessPublisher`'s loops are described under +processor's loop is described under [Managing reviews](#managing-reviews), +and `SubprocessPublisher`'s under [The publisher worker](#the-publisher-worker). ## What each sandbox sees @@ -164,7 +165,42 @@ file's section. | New user namespaces | refused (`--disable-userns`) | | Time limit | 2 min | -## Building a section +## Managing reviews + +The processor (`Processor.run`, in the service) decides what each review +serves: it builds sections from pull requests in the build sandbox, and +hands them to the publisher worker to serve. + +### Review management loop + +```mermaid +flowchart TB + start([start]) --> connected1[wait until the worker
is connected to AcknowlEDG] + connected1 --> restore[restore: serve the stored sections again,
or mark their pull requests dirty if their files are gone] + restore --> connected2[wait until connected] + connected2 --> due[start processing every dirty pull request,
a few at a time] + due --> maintain{6 h since the
last maintenance?} + maintain -->|yes| gc[git housekeeping] --> sleep + maintain -->|no| sleep[wait to be woken,
or 60 s] + sleep --> connected2 + + due -.-> process + subgraph process [Processing one pull request] + open{open, against a
served branch?} + open -->|no| teardown[remove its review,
delete its worktrees] + open -->|yes| fetchbuild[fetch it, build its sections] + fetchbuild --> sync[sync the sections] + sync -->|ok| record[record the sections,
delete superseded runs] + sync -->|unavailable| defer[stays dirty:
retried once connected] + fetchbuild -->|error| retry[retried with backoff:
1 min up to 1 h,
at most 8 times] + sync -->|error| retry + end +``` + +### Building a section + +The "fetch it, build its sections" and "sync the sections" steps of that +loop, for one section: ```mermaid sequenceDiagram @@ -250,34 +286,6 @@ review's sections with the stock providers from `edgacknowledg.client` `SubprocessPublisher`, which implements the same `Publisher` interface, so the rest of the service doesn't know the worker is a separate process. -### Review management loop - -The processor (`Processor.run`) decides what each review serves: - -```mermaid -flowchart TB - start([start]) --> connected1[wait until the worker
is connected to AcknowlEDG] - connected1 --> restore[restore: serve the stored sections again,
or mark their pull requests dirty if their files are gone] - restore --> connected2[wait until connected] - connected2 --> due[start processing every dirty pull request,
a few at a time] - due --> maintain{6 h since the
last maintenance?} - maintain -->|yes| gc[git housekeeping] --> sleep - maintain -->|no| sleep[wait to be woken,
or 60 s] - sleep --> connected2 - - due -.-> process - subgraph process [Processing one pull request] - open{open, against a
served branch?} - open -->|no| teardown[remove its review,
delete its worktrees] - open -->|yes| fetchbuild[fetch it, build its sections] - fetchbuild --> sync[sync the sections] - sync -->|ok| record[record the sections,
delete superseded runs] - sync -->|unavailable| defer[stays dirty:
retried once connected] - fetchbuild -->|error| retry[retried with backoff:
1 min up to 1 h,
at most 8 times] - sync -->|error| retry - end -``` - ### Worker respawn loop `SubprocessPublisher` keeps one worker running, and makes sure a new one