diff --git a/.evergreen/generated_configs/tasks.yml b/.evergreen/generated_configs/tasks.yml index 942967bbec..e2677e7399 100644 --- a/.evergreen/generated_configs/tasks.yml +++ b/.evergreen/generated_configs/tasks.yml @@ -316,80 +316,6 @@ tasks: TOPOLOGY: sharded_cluster tags: [test-min-support] - # Mod wsgi tests - - name: mod-wsgi-replica-set-python3.10 - commands: - - func: run server - vars: - TOPOLOGY: replica_set - TOOLCHAIN_VERSION: "3.10" - - func: run tests - vars: - TEST_NAME: mod_wsgi - SUB_TEST_NAME: standalone - TOOLCHAIN_VERSION: "3.10" - tags: [mod_wsgi, pr] - - name: mod-wsgi-embedded-mode-replica-set-python3.11 - commands: - - func: run server - vars: - TOPOLOGY: replica_set - TOOLCHAIN_VERSION: "3.11" - - func: run tests - vars: - TEST_NAME: mod_wsgi - SUB_TEST_NAME: embedded - TOOLCHAIN_VERSION: "3.11" - tags: [mod_wsgi, pr] - - name: mod-wsgi-replica-set-python3.12 - commands: - - func: run server - vars: - TOPOLOGY: replica_set - TOOLCHAIN_VERSION: "3.12" - - func: run tests - vars: - TEST_NAME: mod_wsgi - SUB_TEST_NAME: standalone - TOOLCHAIN_VERSION: "3.12" - tags: [mod_wsgi, pr] - - name: mod-wsgi-embedded-mode-replica-set-python3.13 - commands: - - func: run server - vars: - TOPOLOGY: replica_set - TOOLCHAIN_VERSION: "3.13" - - func: run tests - vars: - TEST_NAME: mod_wsgi - SUB_TEST_NAME: embedded - TOOLCHAIN_VERSION: "3.13" - tags: [mod_wsgi, pr] - - name: mod-wsgi-embedded-mode-replica-set-python3.14 - commands: - - func: run server - vars: - TOPOLOGY: replica_set - TOOLCHAIN_VERSION: "3.14" - - func: run tests - vars: - TEST_NAME: mod_wsgi - SUB_TEST_NAME: embedded - TOOLCHAIN_VERSION: "3.14" - tags: [mod_wsgi, pr] - - name: mod-wsgi-embedded-mode-replica-set-python3.15 - commands: - - func: run server - vars: - TOPOLOGY: replica_set - TOOLCHAIN_VERSION: "3.15" - - func: run tests - vars: - TEST_NAME: mod_wsgi - SUB_TEST_NAME: embedded - TOOLCHAIN_VERSION: "3.15" - tags: [mod_wsgi, pr] - # No orchestration tests - name: test-no-orchestration-python3.10-min-deps commands: diff --git a/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index de2ce0e5ce..26027888fe 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -384,16 +384,6 @@ buildvariants: TEST_NAME: mockupdb tags: [pr] - # Mod wsgi tests - - name: mod_wsgi-ubuntu-22 - tasks: - - name: .mod_wsgi - display_name: Mod_WSGI Ubuntu-22 - run_on: - - ubuntu2204-small - expansions: - MOD_WSGI_VERSION: "4" - # No c ext tests - name: no-c-ext-rhel8 tasks: diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index f8f8be140a..97fd9b0176 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -400,14 +400,6 @@ def create_no_c_ext_variants(): return [create_variant(tasks, display_name, host=host, expansions=expansions)] -def create_mod_wsgi_variants(): - host = HOSTS["ubuntu22"] - tasks = [".mod_wsgi"] - expansions = dict(MOD_WSGI_VERSION="4") - display_name = get_variant_name("Mod_WSGI", host) - return [create_variant(tasks, display_name, host=host, expansions=expansions)] - - def create_disable_test_commands_variants(): host = DEFAULT_HOST expansions = dict(AUTH="auth", SSL="ssl", DISABLE_TEST_COMMANDS="1") @@ -1012,31 +1004,6 @@ def create_oidc_tasks(): return tasks -def create_mod_wsgi_tasks(): - tasks = [] - for (test, topology), python in zip_cycle( - product(["standalone", "embedded-mode"], ["standalone", "replica_set"]), CPYTHONS - ): - if "t" in python: - continue - if test == "standalone": - task_name = "mod-wsgi-" - else: - task_name = "mod-wsgi-embedded-mode-" - task_name += topology.replace("_", "-") - task_name = get_task_name(task_name, python=python) - server_vars = dict(TOPOLOGY=topology, TOOLCHAIN_VERSION=python) - server_func = FunctionCall(func="run server", vars=server_vars) - vars = dict( - TEST_NAME="mod_wsgi", SUB_TEST_NAME=test.split("-")[0], TOOLCHAIN_VERSION=python - ) - test_func = FunctionCall(func="run tests", vars=vars) - tags = ["mod_wsgi", "pr"] - commands = [server_func, test_func] - tasks.append(EvgTask(name=task_name, tags=tags, commands=commands)) - return tasks - - def _create_ocsp_tasks(algo, variant, server_type, base_task_name): tasks = [] file_name = f"{algo}-basic-tls-ocsp-{variant}.json" diff --git a/.evergreen/scripts/mod_wsgi_matrix.py b/.evergreen/scripts/mod_wsgi_matrix.py new file mode 100644 index 0000000000..ea8add69e9 --- /dev/null +++ b/.evergreen/scripts/mod_wsgi_matrix.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import json + +from generate_config_utils import ALL_VERSIONS, CPYTHONS + +# MongoDB 6.0 with the oldest supported CPython and minimum dependencies, and +# the latest MongoDB with the newest supported CPython. The jobs run on +# ubuntu-22.04 runners, where MongoDB 6.0 is the oldest version that installs. +VERSIONS = [ + { + "python-version": CPYTHONS[0], + "mongodb-version": "6.0", + }, + { + "python-version": CPYTHONS[-1], + "mongodb-version": ALL_VERSIONS[-1], + }, +] + +if __name__ == "__main__": + print(json.dumps(VERSIONS)) diff --git a/.evergreen/scripts/mod_wsgi_smoke_test.sh b/.evergreen/scripts/mod_wsgi_smoke_test.sh new file mode 100755 index 0000000000..d7630cfc25 --- /dev/null +++ b/.evergreen/scripts/mod_wsgi_smoke_test.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Smoke test the mod_wsgi tests in an ubuntu:24.04 container, mirroring the +# Mod WSGI job in .github/workflows/test-python.yml. +set -eu + +SCRIPT_DIR=$(dirname ${BASH_SOURCE:-$0}) +ROOT=$(dirname "$(dirname "$SCRIPT_DIR")") + +# Re-exec the script inside a container when run on the host. +if [ ! -f /.dockerenv ]; then + if ! command -v docker >/dev/null; then + echo "docker is required to run the mod_wsgi smoke test" + exit 1 + fi + exec docker run --rm -v "$ROOT":/src:ro ubuntu:24.04 bash /src/.evergreen/scripts/mod_wsgi_smoke_test.sh +fi + +if [ "$(id -u)" = "0" ]; then + # Apache and mongod must not run as root. Install system packages, copy the + # checkout into a home directory, and re-run this script unprivileged. + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq apache2 apache2-dev build-essential curl jq git >/dev/null + # Install uv to a system path from a pinned release, verifying the published + # checksum, rather than piping a mutable installer script into a shell. + UV_VERSION=0.12.17 + case "$(uname -m)" in + x86_64) UV_TARGET=x86_64-unknown-linux-gnu ;; + aarch64 | arm64) UV_TARGET=aarch64-unknown-linux-gnu ;; + *) echo "Unsupported architecture" >&2; exit 1 ;; + esac + curl -fsSL -o "/tmp/uv-${UV_TARGET}.tar.gz" "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${UV_TARGET}.tar.gz" + curl -fsSL -o "/tmp/uv-${UV_TARGET}.tar.gz.sha256" "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-${UV_TARGET}.tar.gz.sha256" + (cd /tmp && sha256sum -c "uv-${UV_TARGET}.tar.gz.sha256") + tar -xzf "/tmp/uv-${UV_TARGET}.tar.gz" -C /tmp + install -m 0755 "/tmp/uv-${UV_TARGET}/uv" /usr/local/bin/uv + rm -rf "/tmp/uv-${UV_TARGET}.tar.gz" "/tmp/uv-${UV_TARGET}.tar.gz.sha256" "/tmp/uv-${UV_TARGET}" + useradd -m smoke + mkdir -p /home/smoke/src + # Leave out the generated env files so they cannot override the versions + # set below. + tar -C /src -cf - --exclude=.git --exclude=.venv --exclude=.evergreen/scripts/env.sh \ + --exclude=.evergreen/scripts/test-env.sh . | tar -C /home/smoke/src -xf - + chown -R smoke:smoke /home/smoke/src + exec su - smoke -c "bash /src/.evergreen/scripts/mod_wsgi_smoke_test.sh" +fi + +export PATH="$HOME/.local/bin:$PATH" +uv tool install rust-just >/dev/null + +cd /home/smoke/src + +# mongod inherits the soft nofile limit; the 1024 default is exhausted by the +# connection storm the parallel test generates. Best effort: some container +# hosts set a lower hard limit. +ulimit -n 65536 2>/dev/null || true + +# Mirror the GHA job and test the newest supported CPython. +LATEST_PYTHON=$(uv run --no-project --with 'shrub.py>=3.10.0' python .evergreen/scripts/mod_wsgi_matrix.py | jq -r '.[-1]."python-version"') +echo "Testing with CPython $LATEST_PYTHON" +uv python install "$LATEST_PYTHON" >/dev/null + +export UV_PYTHON=$LATEST_PYTHON +export PYMONGO_C_EXT_MUST_BUILD=1 +just install +uv sync --group mod_wsgi + +# Start a single-node replica set. +MARCH=$(uname -m) +if [ "$MARCH" != "aarch64" ] && [ "$MARCH" != "x86_64" ]; then + echo "Unsupported architecture: $MARCH" + exit 1 +fi +MONGODB_URL=$(curl -fsSL https://downloads.mongodb.org/current.json | jq -r " + .versions[] | select(.current and .production_release) | .downloads[] | + select(.target==\"ubuntu2404\" and .arch==\"$MARCH\") | .archive.url" | grep -v enterprise | head -1) +curl -fsSL "$MONGODB_URL" -o /tmp/mongo.tgz +MEMBER=$(tar -tzf /tmp/mongo.tgz | grep "bin/mongod$") +tar -xz -C /tmp --strip-components=2 -f /tmp/mongo.tgz "$MEMBER" +mkdir -p /tmp/db +/tmp/mongod --replSet rs0 --bind_ip 127.0.0.1 --port 27017 --dbpath /tmp/db --fork --logpath /tmp/mongod.log +uv run python -c " +from pymongo import MongoClient +client = MongoClient('127.0.0.1:27017', directConnection=True) +client.admin.command('replSetInitiate', {'_id': 'rs0', 'members': [{'_id': 0, 'host': '127.0.0.1:27017'}]}) +MongoClient().admin.command('hello') +" + +# The mod_wsgi group is part of the synced environment, so both modes can run +# without reinstalling. +for MODE in standalone embedded; do + bash .evergreen/scripts/setup-tests.sh mod_wsgi $MODE + bash .evergreen/run-tests.sh + bash .evergreen/scripts/teardown-tests.sh +done +echo "mod_wsgi smoke test passed" diff --git a/.evergreen/scripts/mod_wsgi_tester.py b/.evergreen/scripts/mod_wsgi_tester.py index 5968849068..2c156a734c 100644 --- a/.evergreen/scripts/mod_wsgi_tester.py +++ b/.evergreen/scripts/mod_wsgi_tester.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import socket import sys import time import urllib.error @@ -22,6 +23,17 @@ def make_request(url, timeout=10): raise TimeoutError(f"Failed to access {url}") +def find_mod_wsgi_so() -> str: + # pip builds the Apache module against the installing interpreter, so the + # .so lives inside the installed package. + import mod_wsgi + + so_files = list((Path(mod_wsgi.__file__).parent / "server").glob("mod_wsgi*.so")) + if len(so_files) != 1: + raise ValueError(f"Expected one mod_wsgi.so in {mod_wsgi.__file__}, found {so_files}") + return str(so_files[0]) + + def setup_mod_wsgi(sub_test_name: str) -> None: env = os.environ.copy() if sub_test_name == "embedded": @@ -34,20 +46,14 @@ def setup_mod_wsgi(sub_test_name: str) -> None: apache = which("apache2") if not apache and Path("/usr/lib/apache2/mpm-prefork/apache2").exists(): apache = "/usr/lib/apache2/mpm-prefork/apache2" - if apache: - apache_config = "apache24ubuntu161404.conf" - else: - apache = which("httpd") - if not apache: - raise ValueError("Could not find apache2 or httpd") - apache_config = "apache22amazon.conf" - python_version = ".".join(str(val) for val in sys.version_info[:2]) - mod_wsgi_version = 4 - so_file = f"/opt/python/mod_wsgi/python_version/{python_version}/mod_wsgi_version/{mod_wsgi_version}/mod_wsgi.so" + if not apache: + raise ValueError("Could not find apache2") + apache_config = "apache24ubuntu.conf" + so_file = find_mod_wsgi_so() write_env("MOD_WSGI_SO", so_file) env["MOD_WSGI_SO"] = so_file - env["PYTHONHOME"] = f"/opt/python/{python_version}" env["PROJECT_DIRECTORY"] = project_directory = str(ROOT) + write_env("PROJECT_DIRECTORY", project_directory) write_env("APACHE_BINARY", apache) write_env("APACHE_CONFIG", apache_config) uri1 = f"http://localhost:8080/interpreter1{project_directory}" @@ -77,9 +83,10 @@ def test_mod_wsgi() -> None: args = f"-n 25000 serial {uri1} {uri2}" main(*parse_args(args.split())) - except Exception as e: + except BaseException: + # The test client raises KeyboardInterrupt in the failing path. LOGGER.error(Path("error_log").read_text()) - raise e + raise def teardown_mod_wsgi() -> None: @@ -88,6 +95,17 @@ def teardown_mod_wsgi() -> None: run_command(f"{apache} -k stop -f {ROOT}/test/mod_wsgi_test/{apache_config}") + # -k stop signals the master process and returns before it exits. Wait for + # the port to be released so another instance can bind it. + deadline = time.time() + 10 + while time.time() < deadline: + try: + socket.create_connection(("127.0.0.1", 8080), timeout=1).close() + except OSError: + return + time.sleep(0.5) + raise ValueError("Apache did not release port 8080") + if __name__ == "__main__": setup_mod_wsgi() diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index a8839533c1..60935b953e 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -47,7 +47,7 @@ # Map the test name to test group. -GROUP_MAP = dict(mockupdb="mockupdb", perf="perf") +GROUP_MAP = dict(mockupdb="mockupdb", perf="perf", mod_wsgi="mod_wsgi") # The python version used for perf tests. PERF_PYTHON_VERSION = "3.10.11" diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 252c9972de..4c40aa1ac7 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -22,7 +22,7 @@ jobs: static: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install Python tooling @@ -76,7 +76,7 @@ jobs: name: Python ${{ matrix.python-version }}-${{ matrix.os }} steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install Python tooling @@ -98,7 +98,7 @@ jobs: name: Coverage steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install Python tooling @@ -123,7 +123,7 @@ jobs: runs-on: ubuntu-latest name: DocTest steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install Python tooling @@ -141,11 +141,123 @@ jobs: just setup-tests doctest just run-tests + mod_wsgi_min_deps: + runs-on: ubuntu-22.04 + timeout-minutes: 60 + name: Mod WSGI Min Deps + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - id: versions + # The versions come from the Evergreen config's version lists. + run: | + JSON=$(uv run --no-project --with 'shrub.py>=3.10.0' python .evergreen/scripts/mod_wsgi_matrix.py) + echo "python=$(jq -r '.[0]."python-version"' <<<"$JSON")" >> "$GITHUB_OUTPUT" + echo "mongodb=$(jq -r '.[0]."mongodb-version"' <<<"$JSON")" >> "$GITHUB_OUTPUT" + - name: Install Python tooling + uses: mongodb-labs/drivers-github-tools/python/setup@1a2d86bd5fed4b0090453bcd510e4c2ba3b92356 # v3.0.4 + with: + python-version: ${{ steps.versions.outputs.python }} + - name: Install Apache + run: | + sudo apt-get update + sudo apt-get install -y apache2 apache2-dev + - name: Install dependencies + run: | + # CI skips the venv setup in setup-dev-env.sh, so create it here. + export PYMONGO_C_EXT_MUST_BUILD=1 + just install + uv sync --group mod_wsgi --refresh-package pymongo + uv run --no-sync python tools/fail_if_no_c.py + - id: setup-mongodb + # Pinned to a commit hash to satisfy CodeQL's ref-pin check. + uses: mongodb-labs/drivers-evergreen-tools@63fb7765ba349b1c8495f722e1942ef39e1b05b9 + with: + version: ${{ steps.versions.outputs.mongodb }} + topology: "replica_set" + - name: Run standalone tests + env: + TEST_MIN_DEPS: "1" + # Call the test scripts directly: the just recipes run an exact + # "uv sync", which would drop the mod_wsgi group. The group is + # resolved with TEST_MIN_DEPS via GROUP_MAP in setup_tests.py, so + # mod_wsgi is tested at the minimum version from pyproject.toml. + run: | + bash .evergreen/scripts/setup-tests.sh mod_wsgi standalone + bash .evergreen/run-tests.sh + - name: Teardown + if: always() + run: bash .evergreen/scripts/teardown-tests.sh + - name: Run embedded tests + env: + TEST_MIN_DEPS: "1" + run: | + bash .evergreen/scripts/setup-tests.sh mod_wsgi embedded + bash .evergreen/run-tests.sh + - name: Teardown embedded + if: always() + run: bash .evergreen/scripts/teardown-tests.sh + + mod_wsgi_latest: + runs-on: ubuntu-22.04 + timeout-minutes: 60 + name: Mod WSGI Latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - id: versions + # The versions come from the Evergreen config's version lists. + run: | + JSON=$(uv run --no-project --with 'shrub.py>=3.10.0' python .evergreen/scripts/mod_wsgi_matrix.py) + echo "python=$(jq -r '.[1]."python-version"' <<<"$JSON")" >> "$GITHUB_OUTPUT" + echo "mongodb=$(jq -r '.[1]."mongodb-version"' <<<"$JSON")" >> "$GITHUB_OUTPUT" + - name: Install Python tooling + uses: mongodb-labs/drivers-github-tools/python/setup@1a2d86bd5fed4b0090453bcd510e4c2ba3b92356 # v3.0.4 + with: + python-version: ${{ steps.versions.outputs.python }} + - name: Install Apache + run: | + sudo apt-get update + sudo apt-get install -y apache2 apache2-dev + - name: Install dependencies + run: | + # CI skips the venv setup in setup-dev-env.sh, so create it here. + export PYMONGO_C_EXT_MUST_BUILD=1 + just install + uv sync --group mod_wsgi --refresh-package pymongo + uv run --no-sync python tools/fail_if_no_c.py + - id: setup-mongodb + # Pinned to a commit hash to satisfy CodeQL's ref-pin check. + uses: mongodb-labs/drivers-evergreen-tools@63fb7765ba349b1c8495f722e1942ef39e1b05b9 + with: + version: ${{ steps.versions.outputs.mongodb }} + topology: "replica_set" + - name: Run standalone tests + # Call the test scripts directly: the just recipes run an exact + # "uv sync", which would drop the mod_wsgi group. + run: | + bash .evergreen/scripts/setup-tests.sh mod_wsgi standalone + bash .evergreen/run-tests.sh + - name: Teardown + if: always() + run: bash .evergreen/scripts/teardown-tests.sh + - name: Run embedded tests + run: | + bash .evergreen/scripts/setup-tests.sh mod_wsgi embedded + bash .evergreen/run-tests.sh + - name: Teardown embedded + if: always() + run: bash .evergreen/scripts/teardown-tests.sh + docs: name: Docs Checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install Python tooling @@ -164,7 +276,7 @@ jobs: matrix: python: ["3.10", "3.11"] steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install Python tooling @@ -181,7 +293,7 @@ jobs: runs-on: ubuntu-latest name: Integration Tests steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install Python tooling @@ -207,7 +319,7 @@ jobs: runs-on: ubuntu-latest name: "Make an sdist" steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-python@v7.0.0 @@ -267,7 +379,7 @@ jobs: runs-on: ubuntu-latest name: Test minimum dependencies and Python steps: - - uses: actions/checkout@v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install Python tooling diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ef90224c2..8389a06c0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -351,14 +351,21 @@ You will need to set up access to the `drivers-test-secrets-role`, see the [Wiki ### mod_wsgi tests -Note: these tests can only be run from an Evergreen Linux host that has the Python toolchain. +Continuous integration runs the tests on every pull request in the Mod WSGI job of +`.github/workflows/test-python.yml`. To run the same steps locally in an ubuntu container, +run `just smoke-mod-wsgi`. -- Run `just run-server`. -- Run `just setup-tests mod_wsgi `. -- Run `just run-tests`. +To run the tests by hand, install Apache and mod_wsgi (`sudo apt-get install -y apache2 +apache2-dev` and `uv sync --group mod_wsgi` on Ubuntu), then: + +- Run `TOPOLOGY=replica_set just run-server`. +- Run `bash .evergreen/scripts/setup-tests.sh mod_wsgi `. +- Run `bash .evergreen/run-tests.sh`. +- Run `bash .evergreen/scripts/teardown-tests.sh`. -The `mode` can be `standalone` or `embedded`. For the `replica_set` version of the tests, use -`TOPOLOGY=replica_set just run-server`. +The `mode` can be `standalone` or `embedded`. Call the test scripts directly +rather than using the `just` recipes: the recipes run an exact `uv sync`, +which drops the `mod_wsgi` group. ### OCSP tests diff --git a/doc/changelog.rst b/doc/changelog.rst index 5fb2fb4731..ddf56b3a9d 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -7,6 +7,14 @@ Changes in Version 4.19.0 (2026/XX/XX) PyMongo 4.19 brings a number of changes including: - Added support for Python 3.15. +- Added support for running PyMongo in subinterpreters, including inside + ``concurrent.futures.InterpreterPoolExecutor`` (Python 3.14+). In + interpreters that do not allow daemon threads, the synchronous client's + monitor threads now start as non-daemon threads and are stopped and joined + when the interpreter is torn down. Note that because these threads are + non-daemon, an interpreter may block on teardown until any in-flight + monitor work completes. Tests cover both the synchronous and asynchronous + clients running in subinterpreters. Bug fixes ......... diff --git a/justfile b/justfile index df678cdbac..80b4ca53e4 100644 --- a/justfile +++ b/justfile @@ -124,6 +124,11 @@ coverage-xml: uv tool run --with "coverage[toml]" coverage xml @echo "Coverage report generated in coverage.xml" +# Run the mod_wsgi tests in an ubuntu container +[group('test')] +smoke-mod-wsgi: + bash .evergreen/scripts/mod_wsgi_smoke_test.sh + # Start a MongoDB server via drivers-evergreen-tools [group('server')] run-server *args="": diff --git a/pymongo/asynchronous/monitor.py b/pymongo/asynchronous/monitor.py index ba0e3804a6..406bf11249 100644 --- a/pymongo/asynchronous/monitor.py +++ b/pymongo/asynchronous/monitor.py @@ -18,6 +18,7 @@ import asyncio import atexit +import threading import time import weakref from typing import TYPE_CHECKING, Any, Optional @@ -492,3 +493,14 @@ def _shutdown_resources() -> None: if _IS_SYNC: atexit.register(_shutdown_resources) + # In subinterpreters, daemon threads are not allowed and the executors' + # threads are joined (unlike atexit, threading._register_atexit runs for + # subinterpreters), so the executors must be stopped before the + # interpreter tries to join them. Probe for that restriction: in the + # main interpreter the assignment always succeeds and the normal atexit + # ordering is preserved. + try: + threading.Thread().daemon = True + except RuntimeError: + if hasattr(threading, "_register_atexit"): + threading._register_atexit(_shutdown_resources) # type: ignore[attr-defined] diff --git a/pymongo/periodic_executor.py b/pymongo/periodic_executor.py index 4b979ca9f9..bf99469b81 100644 --- a/pymongo/periodic_executor.py +++ b/pymongo/periodic_executor.py @@ -185,7 +185,17 @@ def open(self) -> None: if not started: thread = threading.Thread(target=self._run, name=self._name) - thread.daemon = True + try: + # Daemon threads are disabled in subinterpreters unless the + # interpreter was created with allow_daemon_threads=True. + # _shutdown_executors stops and joins the thread during + # interpreter shutdown, so a non-daemon thread is safe. Note + # that if the join times out, interpreter shutdown still + # joins the thread without a timeout, so a stuck target can + # block interpreter teardown. + thread.daemon = True + except RuntimeError: + pass self._thread = weakref.proxy(thread) _register_executor(self) # Mitigation to RuntimeError firing when thread starts on shutdown diff --git a/pymongo/synchronous/monitor.py b/pymongo/synchronous/monitor.py index 9a25757f03..ab86559a1d 100644 --- a/pymongo/synchronous/monitor.py +++ b/pymongo/synchronous/monitor.py @@ -18,6 +18,7 @@ import asyncio import atexit +import threading import time import weakref from typing import TYPE_CHECKING, Any, Optional @@ -490,3 +491,14 @@ def _shutdown_resources() -> None: if _IS_SYNC: atexit.register(_shutdown_resources) + # In subinterpreters, daemon threads are not allowed and the executors' + # threads are joined (unlike atexit, threading._register_atexit runs for + # subinterpreters), so the executors must be stopped before the + # interpreter tries to join them. Probe for that restriction: in the + # main interpreter the assignment always succeeds and the normal atexit + # ordering is preserved. + try: + threading.Thread().daemon = True + except RuntimeError: + if hasattr(threading, "_register_atexit"): + threading._register_atexit(_shutdown_resources) # type: ignore[attr-defined] diff --git a/pyproject.toml b/pyproject.toml index 2adddcd29b..1ad4dd70ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,10 @@ unasync = [ ] pip = ["pip>=20.2"] gevent = ["gevent>=21.12"] +# The test config sets WSGIDestroyInterpreter Off; without it, mod_wsgi 6 +# crashes live pymongo monitor threads when it destroys interpreters as +# child processes exit. +mod_wsgi = ["mod_wsgi>=4.9.4"] coverage = [ "coverage[toml]>=5,<=7.15.0" ] diff --git a/test/asynchronous/test_periodic_executor.py b/test/asynchronous/test_periodic_executor.py index 15186c4f88..97a2e1fd13 100644 --- a/test/asynchronous/test_periodic_executor.py +++ b/test/asynchronous/test_periodic_executor.py @@ -17,17 +17,28 @@ from __future__ import annotations import asyncio +import importlib +import os import sys +import textwrap import threading import time +from typing import Any + +_interpreters: Any = None +if sys.version_info >= (3, 14): + _interpreters = importlib.import_module("concurrent.interpreters") sys.path[0:0] = [""] +import pymongo from pymongo.periodic_executor import AsyncPeriodicExecutor from test.asynchronous import AsyncUnitTest, unittest _IS_SYNC = False +_PYMONGO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(pymongo.__file__))) + class TestAsyncPeriodicExecutor(AsyncUnitTest): def _make_executor(self, interval=30.0, min_interval=0.01, target=None, name="test"): @@ -178,6 +189,38 @@ async def target(): executor._task.exception() self.assertEqual(call_count, 2, "executor should run again after re-open") + async def test_subinterpreter_shutdown(self): + if not _IS_SYNC: + self.skipTest("subinterpreters are only used with the sync driver") + if _interpreters is None: + self.skipTest("concurrent.interpreters requires Python 3.14+") + return + + root = _PYMONGO_ROOT + code = textwrap.dedent( + f""" + import sys + sys.path.insert(0, {root!r}) + from pymongo.periodic_executor import PeriodicExecutor + + def target(): + return True + + executor = PeriodicExecutor( + interval=30.0, min_interval=0.05, target=target, name="subinterp" + ) + executor.open() + """ + ) + interp = _interpreters.create() + try: + interp.exec(code) + finally: + # Destroying the subinterpreter must stop and join the executor + # thread without crashing or hanging. Regression test for + # PYTHON-6114. + interp.close() + if __name__ == "__main__": unittest.main() diff --git a/test/mod_wsgi_test/README.rst b/test/mod_wsgi_test/README.rst index e96db9406c..539ab87f51 100644 --- a/test/mod_wsgi_test/README.rst +++ b/test/mod_wsgi_test/README.rst @@ -13,44 +13,31 @@ mod_wsgi creates. Test Matrix ----------- -PyMongo should be tested with several versions of mod_wsgi and a selection -of Python versions. Each combination of mod_wsgi and Python version should -be tested with a standalone and a replica set. ``mod_wsgi_test.py`` -detects if the deployment is a replica set and connects to the whole set. +Continuous integration tests the oldest supported CPython against MongoDB 6.0 +with minimum dependencies, and the newest supported CPython against the latest +MongoDB, in both daemon and embedded mode against a replica set. MongoDB 6.0 +is used instead of the oldest supported version because the ubuntu-22.04 +runners cannot install older MongoDB versions. The Python versions and the +latest MongoDB version come from ``generate_config_utils.py`` in +``.evergreen/scripts``. Other combinations can be tested manually. Setup ----- -Compile Python +Install Apache .............. -We need a Python interpreter built as a shared library. Download the -source tarball for each Python version tested, untar it, and run:: +On Ubuntu, install Apache and the headers used to build mod_wsgi:: - ./configure --prefix=/some/directory --enable-shared LDFLAGS="-Wl,--rpath=/some/directory/lib" - make - make install + sudo apt-get install -y apache2 apache2-dev -This results in an executable named "python" or "python3" and a shared -library named something like "libpython2.7.so.1.0" or "libpython3.3m.so.1.0". - -Compile mod_wsgi +Install mod_wsgi ................ -Compile mod_wsgi for each combination for Python and mod_wsgi version in the -test matrix. For example, to compile mod_wsgi 3.4 for Python 2.7 on a -RedHat-like Linux:: - - sudo yum install -y httpd httpd-devel - wget https://modwsgi.googlecode.com/files/mod_wsgi-3.4.tar.gz - tar xzf mod_wsgi-3.4.tar.gz - cd mod_wsgi-3.4 - ./configure --with-python=/some/directory/bin/python LDFLAGS="-Wl,--rpath=/some/directory/lib" - make - make install +The project defines a ``mod_wsgi`` dependency group used for testing. pip +builds mod_wsgi against the interpreter it is installed with:: -To ease testing of several matrix combinations, copy the resulting -``mod_wsgi.so`` to a safe place. + uv sync --group mod_wsgi Start mongod ............ @@ -64,7 +51,7 @@ Configure Apache Set a MOD_WSGI_SO environment variable so our ``mod_wsgi_test.conf`` can find and load mod_wsgi.so:: - export MOD_WSGI_SO=/path/to/mod_wsgi.so + export MOD_WSGI_SO=$(find .venv -name "mod_wsgi*.so") Start Apache with one of the config files in this directory. @@ -104,7 +91,8 @@ the workaround added in `PYTHON-569 Automation ---------- -At MongoDB, Inc. we use a continuous integration job that tests each -combination in the matrix. The job starts up Apache, starts a single server -or replica set, and runs ``test_client.py`` with the proper arguments. -See `run-mod-wsgi-tests.sh `_ +Continuous integration runs the test on every pull request in the Mod WSGI +job of `.github/workflows/test-python.yml +`_. +To run the same steps locally, use ``just smoke-mod-wsgi``, which runs an +ubuntu container with Apache, a single-node replica set, and both test modes. diff --git a/test/mod_wsgi_test/apache22amazon.conf b/test/mod_wsgi_test/apache22amazon.conf deleted file mode 100644 index 7755336b07..0000000000 --- a/test/mod_wsgi_test/apache22amazon.conf +++ /dev/null @@ -1,34 +0,0 @@ -# This is a minimal httpd.conf file written for Apache 2.2 on Amazon Linux - -# The modules directory is here by default. -# ServerRoot "/etc/httpd" -DocumentRoot ${PWD} -PidFile ${PWD}/apache2.pid - -User nobody -Group nobody - -# Bind to localhost only, don't require sudo. -Listen 127.0.0.1:8080 - -# Required modules. -LoadModule authz_host_module modules/mod_authz_host.so -# Needed so we can set a custom log location. -LoadModule log_config_module modules/mod_log_config.so - -ErrorLog ${PWD}/error_log -CustomLog ${PWD}/access_log combined - - - AllowOverride None - Order Deny,Allow - Deny from All - - - - AllowOverride None - Order Allow,Deny - Allow from All - - -Include ${PROJECT_DIRECTORY}/test/mod_wsgi_test/${MOD_WSGI_CONF} diff --git a/test/mod_wsgi_test/apache22ubuntu1204.conf b/test/mod_wsgi_test/apache22ubuntu1204.conf deleted file mode 100644 index 9fa4b2060b..0000000000 --- a/test/mod_wsgi_test/apache22ubuntu1204.conf +++ /dev/null @@ -1,29 +0,0 @@ -# This is a minimal httpd.conf file written for Apache 2.2 on Ubuntu 12.04 - -# The modules directory is here on Ubuntu. -ServerRoot "/usr/lib/apache2" -DocumentRoot ${PWD} -PidFile ${PWD}/apache2.pid - -# Bind to localhost only, don't require sudo. -Listen 127.0.0.1:8080 - -# Required modules. -LoadModule authz_host_module modules/mod_authz_host.so - -ErrorLog ${PWD}/error_log -CustomLog ${PWD}/access_log combined - - - AllowOverride None - Order Deny,Allow - Deny from All - - - - AllowOverride None - Order Allow,Deny - Allow from All - - -Include ${PROJECT_DIRECTORY}/test/mod_wsgi_test/${MOD_WSGI_CONF} diff --git a/test/mod_wsgi_test/apache24ubuntu161404.conf b/test/mod_wsgi_test/apache24ubuntu.conf similarity index 71% rename from test/mod_wsgi_test/apache24ubuntu161404.conf rename to test/mod_wsgi_test/apache24ubuntu.conf index eb5414f0f7..4d73183c56 100644 --- a/test/mod_wsgi_test/apache24ubuntu161404.conf +++ b/test/mod_wsgi_test/apache24ubuntu.conf @@ -1,7 +1,12 @@ -# This is a minimal httpd.conf file written for Apache 2.4 on Ubuntu 14.04/16.04 +# This is a minimal httpd.conf file written for Apache 2.4 on Ubuntu. # The modules directory is here on Ubuntu. ServerRoot "/usr/lib/apache2" + +# mod_wsgi 6 deletes thread states of still-running threads when it destroys +# interpreters as child processes exit, which crashes pymongo monitor threads. +# Keep the mod_wsgi 4 lifecycle where child processes do not finalize Python. +WSGIDestroyInterpreter Off DocumentRoot ${PWD} PidFile ${PWD}/apache2.pid diff --git a/test/test_periodic_executor.py b/test/test_periodic_executor.py index e6bbb31a72..622e9ad070 100644 --- a/test/test_periodic_executor.py +++ b/test/test_periodic_executor.py @@ -17,17 +17,28 @@ from __future__ import annotations import asyncio +import importlib +import os import sys +import textwrap import threading import time +from typing import Any + +_interpreters: Any = None +if sys.version_info >= (3, 14): + _interpreters = importlib.import_module("concurrent.interpreters") sys.path[0:0] = [""] +import pymongo from pymongo.periodic_executor import PeriodicExecutor from test import UnitTest, unittest _IS_SYNC = True +_PYMONGO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(pymongo.__file__))) + class TestPeriodicExecutor(UnitTest): def _make_executor(self, interval=30.0, min_interval=0.01, target=None, name="test"): @@ -178,6 +189,38 @@ def target(): executor._task.exception() self.assertEqual(call_count, 2, "executor should run again after re-open") + def test_subinterpreter_shutdown(self): + if not _IS_SYNC: + self.skipTest("subinterpreters are only used with the sync driver") + if _interpreters is None: + self.skipTest("concurrent.interpreters requires Python 3.14+") + return + + root = _PYMONGO_ROOT + code = textwrap.dedent( + f""" + import sys + sys.path.insert(0, {root!r}) + from pymongo.periodic_executor import PeriodicExecutor + + def target(): + return True + + executor = PeriodicExecutor( + interval=30.0, min_interval=0.05, target=target, name="subinterp" + ) + executor.open() + """ + ) + interp = _interpreters.create() + try: + interp.exec(code) + finally: + # Destroying the subinterpreter must stop and join the executor + # thread without crashing or hanging. Regression test for + # PYTHON-6114. + interp.close() + if __name__ == "__main__": unittest.main() diff --git a/test/test_threads.py b/test/test_threads.py index 1b241751ee..9b4585de7e 100644 --- a/test/test_threads.py +++ b/test/test_threads.py @@ -16,7 +16,20 @@ from __future__ import annotations +import sys +import textwrap import threading +import uuid + +try: + from concurrent import interpreters +except ImportError: # pragma: no cover - Python < 3.14 + interpreters = None # type: ignore[assignment] + +try: + from concurrent.futures import InterpreterPoolExecutor +except ImportError: # pragma: no cover - Python < 3.14 + InterpreterPoolExecutor = None # type: ignore[assignment,misc] from test import IntegrationTest, client_context, unittest from test.utils import joinall @@ -160,6 +173,208 @@ def test_safe_update(self): error.join() okay.join() + @staticmethod + def _get_n(queue, n, errors): + try: + return sorted(queue.get(timeout=30) for _ in range(n)) + except interpreters.QueueEmpty: + raise AssertionError(f"subinterpreters failed to run: {errors!r}") from None + + @unittest.skipUnless( + sys.version_info >= (3, 14), "concurrent.interpreters requires Python 3.14+" + ) + def test_subinterpreters(self): + if interpreters is None: + self.skipTest("concurrent.interpreters is not available") + + # Run live MongoClients in more than one subinterpreter at the same + # time. This mirrors the mod_wsgi test, which mounts the same app in + # two interpreters, and covers pymongo shutting down its background + # threads when an interpreter is destroyed (PYTHON-6114). + n_interpreters = 2 + coll_name = f"subinterp-{uuid.uuid4().hex}" + self.addCleanup(self.db.drop_collection, coll_name) + + ready = interpreters.create_queue() + release = interpreters.create_queue() + done = interpreters.create_queue() + code = textwrap.dedent( + """ + import sys + sys.path[:0] = path + + from pymongo import MongoClient + + client = MongoClient(uri, serverSelectionTimeoutMS=30000) + collection = client.get_database(db_name).get_collection(coll_name) + collection.insert_one({"subinterp": i}) + assert collection.find_one({"subinterp": i}) is not None + ready.put(i) + # Hold the client open until every interpreter has connected, so + # that all of the clients are live at the same time. + release.get(timeout=60) + assert collection.find_one({"subinterp": i}) is not None + done.put(i) + """ + ) + + errors: list[BaseException] = [] + + def run(interp): + try: + interp.exec(code) + except BaseException as exc: + errors.append(exc) + + interps = [] + threads = [] + try: + for i in range(n_interpreters): + interp = interpreters.create() + interp.prepare_main( + uri=client_context.uri, + db_name=self.db.name, + coll_name=coll_name, + i=i, + path=tuple(sys.path), + ready=ready, + release=release, + done=done, + ) + interps.append(interp) + thread = threading.Thread(target=run, args=(interp,), name=f"subinterp-{i}") + threads.append(thread) + thread.start() + + started = self._get_n(ready, n_interpreters, errors) + self.assertEqual(started, list(range(n_interpreters))) + for _ in range(n_interpreters): + release.put(True) + + for thread in threads: + thread.join(60) + self.assertFalse(thread.is_alive(), f"{thread.name} did not exit") + + finished = self._get_n(done, n_interpreters, errors) + self.assertEqual(finished, list(range(n_interpreters))) + if errors: + self.fail(f"subinterpreter errors: {errors!r}") + finally: + # Unblock any interpreter still waiting, then destroy them all. + for _ in range(n_interpreters): + release.put(True) + for interp in interps: + # Finalize the idle interpreters: closing one runs + # threading._shutdown, which stops and joins pymongo's + # monitor threads. Skip any that are still executing to + # avoid masking the original error. + if not interp.is_running(): + interp.close() + + found = sorted(doc["subinterp"] for doc in self.db[coll_name].find({}, {"subinterp": 1})) + self.assertEqual(found, list(range(n_interpreters))) + + @unittest.skipUnless( + sys.version_info >= (3, 14), "InterpreterPoolExecutor requires Python 3.14+" + ) + def test_interpreter_pool_executor(self): + if InterpreterPoolExecutor is None: + self.skipTest("InterpreterPoolExecutor is not available") + + # Run live MongoClients inside interpreters managed by the standard + # InterpreterPoolExecutor (PYTHON-5418). The pool's interpreters do + # not allow daemon threads, so pymongo must start non-daemon monitor + # threads and stop them when the interpreter is destroyed. + n_interpreters = 2 + coll_name = f"interp-pool-{uuid.uuid4().hex}" + self.addCleanup(self.db.drop_collection, coll_name) + + # The worker runs in an interpreter whose sys.path has not picked up + # the repo root, so pass the callable as a builtin (exec) and insert + # the main interpreter's sys.path before importing pymongo. + code = textwrap.dedent( + f""" + import sys + sys.path[:0] = {tuple(sys.path)!r} + + from pymongo import MongoClient + + client = MongoClient({client_context.uri!r}, serverSelectionTimeoutMS=30000) + collection = client.get_database({self.db.name!r}).get_collection({coll_name!r}) + collection.insert_one({{"interp-pool": i}}) + assert collection.find_one({{"interp-pool": i}}) is not None + """ + ) + with InterpreterPoolExecutor(max_workers=n_interpreters) as executor: + futures = [executor.submit(exec, code, {"i": i}) for i in range(n_interpreters)] + for future in futures: + future.result(timeout=120) + + found = sorted( + doc["interp-pool"] for doc in self.db[coll_name].find({}, {"interp-pool": 1}) + ) + self.assertEqual(found, list(range(n_interpreters))) + + @unittest.skipUnless( + sys.version_info >= (3, 14), "InterpreterPoolExecutor requires Python 3.14+" + ) + def test_interpreter_pool_executor_async(self): + if InterpreterPoolExecutor is None: + self.skipTest("InterpreterPoolExecutor is not available") + + # Run live AsyncMongoClients inside interpreters managed by the + # standard InterpreterPoolExecutor. The async client runs its + # background tasks on the interpreter's own event loop instead of in + # threads. + n_interpreters = 2 + coll_name = f"interp-pool-async-{uuid.uuid4().hex}" + self.addCleanup(self.db.drop_collection, coll_name) + + # The worker runs in an interpreter whose sys.path has not picked up + # the repo root, so pass the callable as a builtin (exec) and insert + # the main interpreter's sys.path before importing pymongo. + code = textwrap.dedent( + """ + import asyncio + import sys + sys.path[:0] = path + + from pymongo import AsyncMongoClient + + async def main(): + client = AsyncMongoClient(uri, serverSelectionTimeoutMS=30000) + collection = client.get_database(db_name).get_collection(coll_name) + await collection.insert_one({"interp-pool-async": i}) + assert await collection.find_one({"interp-pool-async": i}) is not None + await client.close() + + asyncio.run(main()) + """ + ) + with InterpreterPoolExecutor(max_workers=n_interpreters) as executor: + futures = [ + executor.submit( + exec, + code, + { + "i": i, + "path": tuple(sys.path), + "uri": client_context.uri, + "db_name": self.db.name, + "coll_name": coll_name, + }, + ) + for i in range(n_interpreters) + ] + for future in futures: + future.result(timeout=120) + + found = sorted( + doc["interp-pool-async"] + for doc in self.db[coll_name].find({}, {"interp-pool-async": 1}) + ) + self.assertEqual(found, list(range(n_interpreters))) + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index f80e0c9a46..932a6e68b6 100644 --- a/uv.lock +++ b/uv.lock @@ -58,9 +58,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "exceptiongroup" }, + { name = "idna" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -76,9 +76,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.15'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { 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 = [ @@ -358,9 +358,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jmespath", marker = "python_full_version < '3.10'" }, - { name = "s3transfer", version = "0.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" } }, + { name = "jmespath" }, + { name = "s3transfer", version = "0.16.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5c6fa0bb9fd5caf865b9356411793900304328bcd0bc1eda96a32a1368a6/boto3-1.42.97.tar.gz", hash = "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", size = 113217, upload-time = "2026-04-27T20:39:17.646Z" } wheels = [ @@ -376,9 +376,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "botocore", version = "1.43.89", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "jmespath", marker = "python_full_version >= '3.10'" }, - { name = "s3transfer", version = "0.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "botocore", version = "1.43.89", source = { registry = "https://pypi.org/simple" } }, + { name = "jmespath" }, + { name = "s3transfer", version = "0.19.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/26/48b3da85526a72a02df55e564481fc348e93699c15f0f502681b12ac2c8a/boto3-1.43.89.tar.gz", hash = "sha256:c28abbe472e9b7cad08807356311aeec51bde5218c18489da827045d2267bfd9", size = 112702, upload-time = "2026-09-04T19:24:57.143Z" } wheels = [ @@ -394,9 +394,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "jmespath", marker = "python_full_version < '3.10'" }, - { name = "python-dateutil", marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/95/c37edb602948fad2253ffd1bb3dba5b938645bd1845ee4160350136a0f41/botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", size = 15269348, upload-time = "2026-04-27T20:39:05.625Z" } wheels = [ @@ -412,9 +412,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "jmespath", marker = "python_full_version >= '3.10'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/06/f63fb1befdf77af18539fb24ea01f2da0f13965ed5de091061708ac96416/botocore-1.43.89.tar.gz", hash = "sha256:f0574942970742657b0e0716cf08c2dfe6bef8e6de5fbb7081c3424e262b4cca", size = 16074206, upload-time = "2026-09-04T19:24:52.464Z" } wheels = [ @@ -439,7 +439,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -537,7 +537,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -830,7 +830,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ @@ -976,7 +976,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "tomli" }, ] [[package]] @@ -1083,7 +1083,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -1353,8 +1353,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } wheels = [ @@ -1418,9 +1418,9 @@ resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ @@ -1520,7 +1520,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1554,10 +1554,10 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython'" }, - { name = "zope-event", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event", version = "6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" } wheels = [ @@ -1617,10 +1617,10 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", version = "3.5.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython'" }, - { name = "zope-event", version = "6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "zope-interface", version = "8.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", version = "3.5.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event", version = "6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "zope-interface", version = "8.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/b8/eb/5f2db8013f1a4a6df2c23201f384a066f13ff5764a9f62a608c8a50ac8cc/gevent-26.8.0.tar.gz", hash = "sha256:96039f41bbde6dcd72559e5ffbd408a04f46774b47d991d4cf032da8fa79e5a0", size = 6625998, upload-time = "2026-08-10T18:02:28.038Z" } wheels = [ @@ -1903,7 +1903,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1919,7 +1919,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } wheels = [ @@ -2225,6 +2225,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/c0/bc0c3e82bf91993bf2337b125c4d49c3cadae39f5a4c642baada958f976e/mockupdb-1.8.1-py2.py3-none-any.whl", hash = "sha256:8eb76212963bad5df7f3ea8f26af916087c2dcc69a0457c75328abad44956dbb", size = 27578, upload-time = "2021-10-15T19:02:55.489Z" }, ] +[[package]] +name = "mod-wsgi" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/4d/275853efa4d2a4efe1614aa0df06295e71f2082cebccc4d40d0f0a551c24/mod_wsgi-5.0.2.tar.gz", hash = "sha256:6fe5557c3ef25c184c2c994905fe52be1c06ae5c46235ecafe558e84789e2159", size = 498627, upload-time = "2024-11-22T08:53:22.752Z" } + +[[package]] +name = "mod-wsgi" +version = "6.0.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/ce/fdbfe51f596e37c657176ea64d637737074214bfcd21c2177aa6b1f1da14/mod_wsgi-6.0.6.tar.gz", hash = "sha256:897f58ee961be7dcfe527074dd64d43d18ca108cf919d4c3bcf10fa6a4c12921", size = 607559, upload-time = "2026-08-19T09:51:18.52Z" } + [[package]] name = "mypy" version = "1.19.1" @@ -2463,6 +2483,10 @@ lint = [ mockupdb = [ { name = "mockupdb" }, ] +mod-wsgi = [ + { name = "mod-wsgi", version = "5.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mod-wsgi", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] perf = [ { name = "simplejson" }, ] @@ -2516,6 +2540,7 @@ dev = [] gevent = [{ name = "gevent", specifier = ">=21.12" }] lint = [{ name = "ruff", specifier = "==0.15.20" }] mockupdb = [{ name = "mockupdb", specifier = ">=1.8.1" }] +mod-wsgi = [{ name = "mod-wsgi", specifier = ">=4.9.4" }] perf = [{ name = "simplejson", specifier = ">=3.17.0" }] pip = [{ name = "pip", specifier = ">=20.2" }] typing = [ @@ -2572,8 +2597,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ @@ -2590,8 +2615,8 @@ resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", ] dependencies = [ - { name = "cryptography", version = "50.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, - { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.13'" }, + { name = "cryptography", version = "50.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } wheels = [ @@ -2620,13 +2645,13 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -2642,13 +2667,13 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] 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 = [ @@ -2664,9 +2689,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "backports-asyncio-runner" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ @@ -2682,9 +2707,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" } }, + { 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 = [ @@ -2734,10 +2759,10 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "certifi", marker = "python_full_version < '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ @@ -2753,10 +2778,10 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -2777,7 +2802,7 @@ name = "roman-numerals-py" version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "roman-numerals", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } wheels = [ @@ -2818,7 +2843,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" } wheels = [ @@ -2834,7 +2859,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "botocore", version = "1.43.89", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "botocore", version = "1.43.89", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } wheels = [ @@ -3020,24 +3045,24 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "babel", marker = "python_full_version < '3.10'" }, - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.10'" }, - { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jinja2", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" } }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } wheels = [ @@ -3052,23 +3077,23 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "babel", marker = "python_full_version == '3.10.*'" }, - { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version == '3.10.*'" }, - { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "jinja2", marker = "python_full_version == '3.10.*'" }, - { name = "packaging", marker = "python_full_version == '3.10.*'" }, - { name = "pygments", marker = "python_full_version == '3.10.*'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -3083,23 +3108,23 @@ resolution-markers = [ "python_full_version >= '3.11'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "babel", marker = "python_full_version >= '3.11'" }, - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [ @@ -3116,16 +3141,16 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "colorama" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "uvicorn", version = "0.52.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "watchfiles", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "watchfiles", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "watchfiles", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } @@ -3141,12 +3166,12 @@ resolution-markers = [ "python_full_version >= '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "uvicorn", version = "0.52.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "watchfiles", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "colorama" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "uvicorn", version = "0.52.4", source = { registry = "https://pypi.org/simple" } }, + { name = "watchfiles", version = "1.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ @@ -3280,8 +3305,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } wheels = [ @@ -3297,8 +3322,8 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ @@ -3426,9 +3451,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "h11", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" } }, + { name = "h11" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/4f/f9fdac7cf6dd79790eb165639b5c452ceeabc7bbabbba4569155470a287d/uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302", size = 82001, upload-time = "2025-12-21T13:05:17.973Z" } wheels = [ @@ -3444,9 +3469,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "click", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "h11", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, + { name = "click", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } wheels = [ @@ -3462,7 +3487,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ @@ -3585,7 +3610,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ @@ -4135,7 +4160,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/d8/9c8b0c6bb1db09725395618f68d3b8a08089fca0aed28437500caaf713ee/zope_event-6.0.tar.gz", hash = "sha256:0ebac894fa7c5f8b7a89141c272133d8c1de6ddc75ea4b1f327f00d1f890df92", size = 18731, upload-time = "2025-09-12T07:10:13.551Z" } wheels = [