From 527ee7b28a5b7e45182442d4ef48a084b065f436 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:02:22 -0800 Subject: [PATCH 01/29] Fix: Remove invalid false argument from --skip-missing-interpreters flag --- .github/workflows/check.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 68120bde6..068f5b17b 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -106,7 +106,7 @@ jobs: echo "TOXENV=$py" >> "$GITHUB_ENV" echo "Set TOXENV=$py" - name: 🏗️ Setup test suite - run: tox run -vvvv --notest --skip-missing-interpreters false + run: tox run -vvvv --notest --skip-missing-interpreters - name: 🏃 Run test suite run: tox run --skip-pkg-install timeout-minutes: 20 @@ -143,6 +143,6 @@ jobs: with: fetch-depth: 0 - name: 🏗️ Setup check suite - run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} + run: tox run -vv --notest --skip-missing-interpreters -e ${{ matrix.tox_env }} - name: 🏃 Run check for ${{ matrix.tox_env }} run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} From c48910c4101f8d9fd82e4ec82b4827dd32f3d218 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:05:15 -0800 Subject: [PATCH 02/29] Remove non-existent --skip-missing-interpreters flag from tox commands --- .github/workflows/check.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 068f5b17b..cf3ca2995 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -106,7 +106,7 @@ jobs: echo "TOXENV=$py" >> "$GITHUB_ENV" echo "Set TOXENV=$py" - name: 🏗️ Setup test suite - run: tox run -vvvv --notest --skip-missing-interpreters + run: tox run -vvvv --notest - name: 🏃 Run test suite run: tox run --skip-pkg-install timeout-minutes: 20 @@ -143,6 +143,6 @@ jobs: with: fetch-depth: 0 - name: 🏗️ Setup check suite - run: tox run -vv --notest --skip-missing-interpreters -e ${{ matrix.tox_env }} + run: tox run -vv --notest -e ${{ matrix.tox_env }} - name: 🏃 Run check for ${{ matrix.tox_env }} run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} From 90b4efb1fb938b9db049a49676a6f5b4fa0a8043 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:08:23 -0800 Subject: [PATCH 03/29] Upgrade tox to >=4.0 for Python 3.14 compatibility --- .github/workflows/check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index cf3ca2995..015d30603 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -63,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --no-managed-python --python 3.14 tox --with . + uv tool install --no-managed-python --python 3.14 "tox>=4.0" --with . else - uv tool install --no-managed-python --python 3.14 tox --with tox-uv --with . + uv tool install --no-managed-python --python 3.14 "tox>=4.0" --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 @@ -137,7 +137,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv + run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.0" --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: From 57adcd02be736d47ca86cab55d6fc08c19657be7 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:10:21 -0800 Subject: [PATCH 04/29] Remove tox version constraint to resolve circular dependency --- .github/workflows/check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 015d30603..cf3ca2995 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -63,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --no-managed-python --python 3.14 "tox>=4.0" --with . + uv tool install --no-managed-python --python 3.14 tox --with . else - uv tool install --no-managed-python --python 3.14 "tox>=4.0" --with tox-uv --with . + uv tool install --no-managed-python --python 3.14 tox --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 @@ -137,7 +137,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.0" --with tox-uv + run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: From 6d9f352763b8127f4b9342b65caf65f43909b9cc Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:13:16 -0800 Subject: [PATCH 05/29] Use Python 3.13 to install tox for Python 3.14 compatibility --- .github/workflows/check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index cf3ca2995..266f52774 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -63,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --no-managed-python --python 3.14 tox --with . + uv tool install --no-managed-python --python 3.13 tox --with . else - uv tool install --no-managed-python --python 3.14 tox --with tox-uv --with . + uv tool install --no-managed-python --python 3.13 tox --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 @@ -137,7 +137,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv + run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: From 9d5532b061470cf355688376e70d182e9b7546c3 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:15:09 -0800 Subject: [PATCH 06/29] Fix: Allow uv to download Python 3.13 for tox --- .github/workflows/check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 266f52774..5f929fc73 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -63,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --no-managed-python --python 3.13 tox --with . + uv tool install --python 3.13 tox --with . else - uv tool install --no-managed-python --python 3.13 tox --with tox-uv --with . + uv tool install --python 3.13 tox --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 @@ -137,7 +137,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python-preference only-managed --python 3.13 tox --with tox-uv + run: uv tool install --python 3.13 tox --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: From 8c647a56f76a831ec0365adb27ff65840c6d80ae Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:17:50 -0800 Subject: [PATCH 07/29] Pin tox to 4.11.3 for compatibility --- .github/workflows/check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 5f929fc73..b87dfcc5a 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -63,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --python 3.13 tox --with . + uv tool install --python 3.13 tox==4.11.3 --with . else - uv tool install --python 3.13 tox --with tox-uv --with . + uv tool install --python 3.13 tox==4.11.3 --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 @@ -137,7 +137,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python 3.13 tox --with tox-uv + run: uv tool install --python 3.13 tox==4.11.3 --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: From 5c8bede244cf81bead3454be339234d28d4634e9 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:19:55 -0800 Subject: [PATCH 08/29] Use tox 4.0.14 for virtualenv compatibility --- .github/workflows/check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index b87dfcc5a..c24afe1e0 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -63,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --python 3.13 tox==4.11.3 --with . + uv tool install --python 3.13 tox==4.0.14 --with . else - uv tool install --python 3.13 tox==4.11.3 --with tox-uv --with . + uv tool install --python 3.13 tox==4.0.14 --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 @@ -137,7 +137,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python 3.13 tox==4.11.3 --with tox-uv + run: uv tool install --python 3.13 tox==4.0.14 --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: From 483572ff58a771c6e034c301f10a2b63893c70eb Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:20:57 -0800 Subject: [PATCH 09/29] Remove --with . from tox install to avoid version conflict --- .github/workflows/check.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index c24afe1e0..bdfd03b82 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -63,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --python 3.13 tox==4.0.14 --with . + uv tool install --python 3.13 tox==4.0.14 else - uv tool install --python 3.13 tox==4.0.14 --with tox-uv --with . + uv tool install --python 3.13 tox==4.0.14 --with tox-uv fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 From 6609545a9ec97854458214c5346f8be740c9f07b Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:30:25 -0800 Subject: [PATCH 10/29] Remove tox-uv dependency to fix version conflict --- .github/workflows/check.yaml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index bdfd03b82..0238ba2bc 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -62,11 +62,7 @@ jobs: - name: 📦 Install tox with this virtualenv shell: bash run: | - if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --python 3.13 tox==4.0.14 - else - uv tool install --python 3.13 tox==4.0.14 --with tox-uv - fi + uv tool install --python 3.13 tox==4.0.14 - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 if: ${{ !startsWith(matrix.py, 'brew@') }} @@ -137,7 +133,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python 3.13 tox==4.0.14 --with tox-uv + run: uv tool install --python 3.13 tox==4.0.14 - name: 📥 Checkout code uses: actions/checkout@v4 with: From b09281e40b694db5822bb0fd9daef4f2b14a1f1c Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:47:09 -0800 Subject: [PATCH 11/29] Use Python 3.14 for tox to avoid download delay --- .github/workflows/check.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 0238ba2bc..180cedaef 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -62,7 +62,7 @@ jobs: - name: 📦 Install tox with this virtualenv shell: bash run: | - uv tool install --python 3.13 tox==4.0.14 + uv tool install --python 3.14 tox==4.0.14 - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 if: ${{ !startsWith(matrix.py, 'brew@') }} @@ -133,7 +133,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python 3.13 tox==4.0.14 + run: uv tool install --python 3.14 tox==4.0.14 - name: 📥 Checkout code uses: actions/checkout@v4 with: From 54e3972e2d980a0e0daeefd650d84601799a5c7c Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:53:57 -0800 Subject: [PATCH 12/29] Add --skip-pkg-install to setup step and add timeout --- .github/workflows/check.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 180cedaef..e2598f875 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -102,7 +102,8 @@ jobs: echo "TOXENV=$py" >> "$GITHUB_ENV" echo "Set TOXENV=$py" - name: 🏗️ Setup test suite - run: tox run -vvvv --notest + run: tox run -vvvv --notest --skip-pkg-install + timeout-minutes: 10 - name: 🏃 Run test suite run: tox run --skip-pkg-install timeout-minutes: 20 From ac360516a9f789f5dd656087cd0a22ad469df98b Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 01:58:29 -0800 Subject: [PATCH 13/29] Remove --skip-pkg-install from setup step to install dependencies --- .github/workflows/check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index e2598f875..38fbfa8a6 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -102,7 +102,7 @@ jobs: echo "TOXENV=$py" >> "$GITHUB_ENV" echo "Set TOXENV=$py" - name: 🏗️ Setup test suite - run: tox run -vvvv --notest --skip-pkg-install + run: tox run -vvvv --notest timeout-minutes: 10 - name: 🏃 Run test suite run: tox run --skip-pkg-install From 4e71d015c1d13fdc259dda486e59d57a7cd0f171 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 02:12:44 -0800 Subject: [PATCH 14/29] Revert Workflow Changes --- .github/workflows/check.yaml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 38fbfa8a6..a231e7d6a 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -62,7 +62,11 @@ jobs: - name: 📦 Install tox with this virtualenv shell: bash run: | - uv tool install --python 3.14 tox==4.0.14 + if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then + uv tool install --no-managed-python --python 3.14 tox --with . + else + uv tool install --no-managed-python --python 3.14 tox --with tox-uv --with . + fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 if: ${{ !startsWith(matrix.py, 'brew@') }} @@ -102,8 +106,7 @@ jobs: echo "TOXENV=$py" >> "$GITHUB_ENV" echo "Set TOXENV=$py" - name: 🏗️ Setup test suite - run: tox run -vvvv --notest - timeout-minutes: 10 + run: tox run -vvvv --notest --skip-missing-interpreters false - name: 🏃 Run test suite run: tox run --skip-pkg-install timeout-minutes: 20 @@ -134,12 +137,12 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python 3.14 tox==4.0.14 + run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: 🏗️ Setup check suite - run: tox run -vv --notest -e ${{ matrix.tox_env }} + run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} - name: 🏃 Run check for ${{ matrix.tox_env }} - run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} + run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} \ No newline at end of file From 55e1e84f838154219f0d67e098cdfc7015649c16 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 02:30:21 -0800 Subject: [PATCH 15/29] Pin tox v4 in CI --- .github/workflows/check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index a231e7d6a..fa681dada 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -63,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --no-managed-python --python 3.14 tox --with . + uv tool install --no-managed-python --python 3.14 "tox>=4.28" --with . else - uv tool install --no-managed-python --python 3.14 tox --with tox-uv --with . + uv tool install --no-managed-python --python 3.14 "tox>=4.28" --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 @@ -137,7 +137,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv + run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.28" --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: From 9c56d7531f99d8a4d5e0f46a37416deb5e058999 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 02:31:48 -0800 Subject: [PATCH 16/29] Pin tox v4 in CI --- .github/workflows/check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index fa681dada..9c2188c12 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -137,7 +137,7 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.28" --with tox-uv + run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.32" --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: From 60e3deb99eddb789f0e52708819226c27fe0f603 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 02:32:06 -0800 Subject: [PATCH 17/29] Pin tox v4 in CI --- .github/workflows/check.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 9c2188c12..83573189e 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -63,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --no-managed-python --python 3.14 "tox>=4.28" --with . + uv tool install --no-managed-python --python 3.14 "tox>=4.32" --with . else - uv tool install --no-managed-python --python 3.14 "tox>=4.28" --with tox-uv --with . + uv tool install --no-managed-python --python 3.14 "tox>=4.32" --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 From 4ddf0b84ee8c66a53278539d440a8c097374e1be Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 02:36:02 -0800 Subject: [PATCH 18/29] Fetch upstream tags in CI --- .github/workflows/check.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 83573189e..f46976ddf 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -55,6 +55,10 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + - name: 🏷️ Fetch upstream tags for versioning + shell: bash + run: | + git fetch --force --tags https://github.com/pypa/virtualenv.git - name: 🐍 Setup Python for tox uses: actions/setup-python@v5 with: @@ -142,6 +146,10 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + - name: 🏷️ Fetch upstream tags for versioning + shell: bash + run: | + git fetch --force --tags https://github.com/pypa/virtualenv.git - name: 🏗️ Setup check suite run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} - name: 🏃 Run check for ${{ matrix.tox_env }} From 1ce27edb909e4a3d96bf4ac6670bae6abbb196a7 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 19:41:36 -0800 Subject: [PATCH 19/29] Add PEP 440 version specifier support for --python flag --- src/virtualenv/discovery/builtin.py | 5 +- src/virtualenv/discovery/py_info.py | 29 +++++++++ src/virtualenv/discovery/py_spec.py | 88 +++++++++++++++++++++++++- tests/unit/discovery/test_discovery.py | 27 ++++++++ tests/unit/discovery/test_py_spec.py | 27 ++++++++ 5 files changed, 172 insertions(+), 4 deletions(-) diff --git a/src/virtualenv/discovery/builtin.py b/src/virtualenv/discovery/builtin.py index e2d193911..43e48fa87 100644 --- a/src/virtualenv/discovery/builtin.py +++ b/src/virtualenv/discovery/builtin.py @@ -46,8 +46,9 @@ def add_parser_arguments(cls, parser: ArgumentParser) -> None: type=str, action="append", default=[], - help="interpreter based on what to create environment (path/identifier) " - "- by default use the interpreter where the tool is installed - first found wins", + help="interpreter based on what to create environment (path/identifier/version-specifier) " + "- by default use the interpreter where the tool is installed - first found wins. " + "Version specifiers (e.g., >=3.12, ~=3.11.0, ==3.10) are also supported", ) parser.add_argument( "--try-first-with", diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py index c2310cd7e..1aa43786f 100644 --- a/src/virtualenv/discovery/py_info.py +++ b/src/virtualenv/discovery/py_info.py @@ -16,8 +16,20 @@ import sysconfig import warnings from collections import OrderedDict, namedtuple +from pathlib import Path from string import digits +try: + from packaging.version import Version +except ModuleNotFoundError: # pragma: no cover - fallback for non-site virtual executions + try: + site_packages = Path(__file__).resolve().parents[2] + if site_packages.exists(): + sys.path.insert(0, str(site_packages)) + from packaging.version import Version + except ModuleNotFoundError: + Version = None # Will be unavailable for version specifier matching + VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) # noqa: PYI024 LOGGER = logging.getLogger(__name__) @@ -422,6 +434,23 @@ def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911 if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: return False + if spec.version_specifier is not None: + if Version is None: + return False # Can't match version specifiers without packaging library + version_info = self.version_info + release = f"{version_info.major}.{version_info.minor}.{version_info.micro}" + if version_info.releaselevel != "final": + suffix = { + "alpha": "a", + "beta": "b", + "candidate": "rc", + }.get(version_info.releaselevel) + if suffix is not None: + release = f"{release}{suffix}{version_info.serial}" + version = Version(release) + if not spec.version_specifier.contains(version): + return False + for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro)): if req is not None and our is not None and our != req: return False diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py index d8519c23d..983f31507 100644 --- a/src/virtualenv/discovery/py_spec.py +++ b/src/virtualenv/discovery/py_spec.py @@ -4,8 +4,21 @@ import os import re +import sys +from pathlib import Path + +try: + from packaging.specifiers import InvalidSpecifier, SpecifierSet + from packaging.version import InvalidVersion, Version +except ModuleNotFoundError: # pragma: no cover - fallback for non-site virtual executions + site_packages = Path(__file__).resolve().parents[2] + if site_packages.exists(): + sys.path.insert(0, str(site_packages)) + from packaging.specifiers import InvalidSpecifier, SpecifierSet + from packaging.version import InvalidVersion, Version PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?Pt)?(?:-(?P32|64))?$") +SPECIFIER_PATTERN = re.compile(r"^(?:(?P[A-Za-z]+)\s*)?(?P(?:===|==|~=|!=|<=|>=|<|>).+)$") class PythonSpec: @@ -22,6 +35,7 @@ def __init__( # noqa: PLR0913 path: str | None, *, free_threaded: bool | None = None, + version_specifier: SpecifierSet | None = None, ) -> None: self.str_spec = str_spec self.implementation = implementation @@ -31,10 +45,12 @@ def __init__( # noqa: PLR0913 self.free_threaded = free_threaded self.architecture = architecture self.path = path + self.version_specifier = version_specifier @classmethod def from_string_spec(cls, string_spec: str): # noqa: C901, PLR0912 impl, major, minor, micro, threaded, arch, path = None, None, None, None, None, None, None + version_specifier = None if os.path.isabs(string_spec): # noqa: PLR1702 path = string_spec else: @@ -72,9 +88,41 @@ def _int_or_none(val): arch = _int_or_none(groups["arch"]) if not ok: + specifier_match = SPECIFIER_PATTERN.match(string_spec.strip()) + if specifier_match: + impl = specifier_match.group("impl") + spec_text = specifier_match.group("spec").strip() + try: + version_specifier = SpecifierSet(spec_text) + except InvalidSpecifier: + pass + else: + if impl in {"py", "python"}: + impl = None + return cls( + string_spec, + impl, + None, + None, + None, + None, + None, + free_threaded=None, + version_specifier=version_specifier, + ) path = string_spec - return cls(string_spec, impl, major, minor, micro, arch, path, free_threaded=threaded) + return cls( + string_spec, + impl, + major, + minor, + micro, + arch, + path, + free_threaded=threaded, + version_specifier=version_specifier, + ) def generate_re(self, *, windows: bool) -> re.Pattern: """Generate a regular expression for matching against a filename.""" @@ -113,6 +161,33 @@ def satisfies(self, spec): if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: return False + if spec.version_specifier is not None: + components: list[int] = [] + for part in (self.major, self.minor, self.micro): + if part is None: + break + components.append(part) + if components: + try: + candidate = Version(".".join(str(part) for part in components)) + except InvalidVersion: + candidate = None + else: + for item in spec.version_specifier: + base_version = item.version + if base_version.endswith(".*"): + base_version = base_version[:-2] + if not base_version: + continue + try: + required_precision = len(Version(base_version).release) + except InvalidVersion: + continue + if len(components) < required_precision: + continue + if not item.contains(candidate): + return False + for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)): if req is not None and our is not None and our != req: return False @@ -120,7 +195,16 @@ def satisfies(self, spec): def __repr__(self) -> str: name = type(self).__name__ - params = "implementation", "major", "minor", "micro", "architecture", "path", "free_threaded" + params = ( + "implementation", + "major", + "minor", + "micro", + "architecture", + "path", + "free_threaded", + "version_specifier", + ) return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})" diff --git a/tests/unit/discovery/test_discovery.py b/tests/unit/discovery/test_discovery.py index e6c78e2e3..06fdfd1ab 100644 --- a/tests/unit/discovery/test_discovery.py +++ b/tests/unit/discovery/test_discovery.py @@ -329,3 +329,30 @@ def test_returns_first_python_specified_when_no_env_var_is_specified(mocker, mon result = builtin.run() assert result == mocker.sentinel.python_from_cli + + +def test_discovery_via_version_specifier(session_app_data): + """Test that version specifiers like >=3.11 work correctly.""" + current = PythonInfo.current_system(session_app_data) + major, minor = current.version_info.major, current.version_info.minor + + # Test with >= specifier that should match current Python + spec = f">={major}.{minor}" + interpreter = get_interpreter(spec, [], session_app_data) + assert interpreter is not None + assert interpreter.version_info.major == major + assert interpreter.version_info.minor >= minor + + # Test with compound specifier + spec = f">={major}.{minor},<{major}.{minor + 10}" + interpreter = get_interpreter(spec, [], session_app_data) + assert interpreter is not None + assert interpreter.version_info.major == major + assert minor <= interpreter.version_info.minor < minor + 10 + + # Test with implementation prefix + spec = f"cpython>={major}.{minor}" + interpreter = get_interpreter(spec, [], session_app_data) + if current.implementation == "CPython": + assert interpreter is not None + assert interpreter.implementation == "CPython" diff --git a/tests/unit/discovery/test_py_spec.py b/tests/unit/discovery/test_py_spec.py index 0841019ec..dac91fda8 100644 --- a/tests/unit/discovery/test_py_spec.py +++ b/tests/unit/discovery/test_py_spec.py @@ -4,6 +4,7 @@ from copy import copy import pytest +from packaging.specifiers import SpecifierSet from virtualenv.discovery.py_spec import PythonSpec @@ -132,3 +133,29 @@ def test_relative_spec(tmp_path, monkeypatch): a_relative_path = str((tmp_path / "a" / "b").relative_to(tmp_path)) spec = PythonSpec.from_string_spec(a_relative_path) assert spec.path == a_relative_path + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + (">=3.12", ">=3.12"), + ("python>=3.12", ">=3.12"), + ("cpython!=3.11.*", "!=3.11.*"), + ("<=3.13,>=3.12", "<=3.13,>=3.12"), + ], +) +def test_specifier_parsing(text, expected): + spec = PythonSpec.from_string_spec(text) + assert spec.version_specifier == SpecifierSet(expected) + + +def test_specifier_with_implementation(): + spec = PythonSpec.from_string_spec("cpython>=3.12") + assert spec.implementation == "cpython" + assert spec.version_specifier == SpecifierSet(">=3.12") + + +def test_specifier_satisfies_with_partial_information(): + spec = PythonSpec.from_string_spec(">=3.12") + candidate = PythonSpec.from_string_spec("python3.12") + assert candidate.satisfies(spec) is True From a5d3f702f38255103a030ecf3995b8e3619a709a Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 20:45:29 -0800 Subject: [PATCH 20/29] Make packaging library optional in py_spec.py for zipapp compatibility --- src/virtualenv/discovery/py_spec.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py index 983f31507..9ffc9a5cf 100644 --- a/src/virtualenv/discovery/py_spec.py +++ b/src/virtualenv/discovery/py_spec.py @@ -11,11 +11,18 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.version import InvalidVersion, Version except ModuleNotFoundError: # pragma: no cover - fallback for non-site virtual executions - site_packages = Path(__file__).resolve().parents[2] - if site_packages.exists(): - sys.path.insert(0, str(site_packages)) - from packaging.specifiers import InvalidSpecifier, SpecifierSet - from packaging.version import InvalidVersion, Version + try: + site_packages = Path(__file__).resolve().parents[2] + if site_packages.exists(): + sys.path.insert(0, str(site_packages)) + from packaging.specifiers import InvalidSpecifier, SpecifierSet + from packaging.version import InvalidVersion, Version + except ModuleNotFoundError: + # Packaging not available - version specifier support will be disabled + InvalidSpecifier = Exception # type: ignore[misc, assignment] + SpecifierSet = None # type: ignore[misc, assignment] + InvalidVersion = Exception # type: ignore[misc, assignment] + Version = None # type: ignore[misc, assignment] PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?Pt)?(?:-(?P32|64))?$") SPECIFIER_PATTERN = re.compile(r"^(?:(?P[A-Za-z]+)\s*)?(?P(?:===|==|~=|!=|<=|>=|<|>).+)$") @@ -89,7 +96,7 @@ def _int_or_none(val): if not ok: specifier_match = SPECIFIER_PATTERN.match(string_spec.strip()) - if specifier_match: + if specifier_match and SpecifierSet is not None: impl = specifier_match.group("impl") spec_text = specifier_match.group("spec").strip() try: From 64c256ed5d61752b45ef82cbd8e98f7fbfe1f941 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 21:35:03 -0800 Subject: [PATCH 21/29] increase test timeout --- .github/workflows/check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index f46976ddf..48cd7fa5f 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -113,7 +113,7 @@ jobs: run: tox run -vvvv --notest --skip-missing-interpreters false - name: 🏃 Run test suite run: tox run --skip-pkg-install - timeout-minutes: 20 + timeout-minutes: 30 env: PYTEST_ADDOPTS: "-vv --durations=20" CI_RUN: "yes" From 6bc406ea4fd66c6c1fb86427ab147c96e68f3839 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 21:41:24 -0800 Subject: [PATCH 22/29] Add linter fixes, changelog entry, and documentation --- .github/workflows/check.yaml | 2 +- docs/changelog/2994.feature.rst | 1 + docs/cli_interface.rst | 1 + src/virtualenv/discovery/py_info.py | 2 +- src/virtualenv/discovery/py_spec.py | 2 +- tasks/update_embedded.py | 2 +- 6 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 docs/changelog/2994.feature.rst diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 48cd7fa5f..c6e6ba93b 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -153,4 +153,4 @@ jobs: - name: 🏗️ Setup check suite run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} - name: 🏃 Run check for ${{ matrix.tox_env }} - run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} \ No newline at end of file + run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} diff --git a/docs/changelog/2994.feature.rst b/docs/changelog/2994.feature.rst new file mode 100644 index 000000000..4ef27c708 --- /dev/null +++ b/docs/changelog/2994.feature.rst @@ -0,0 +1 @@ +Add support for PEP 440 version specifiers in the ``--python`` flag. Users can now specify Python versions using operators like ``>=``, ``<=``, ``~=``, etc. For example: ``virtualenv --python=">=3.12" myenv`` - by :user:`rahuldevikar`. diff --git a/docs/cli_interface.rst b/docs/cli_interface.rst index 27ea231ef..fd3543b35 100644 --- a/docs/cli_interface.rst +++ b/docs/cli_interface.rst @@ -38,6 +38,7 @@ To avoid confusion, it's best to think of them as the "rule" and the "hint". This flag sets the mandatory requirements for the interpreter. The ```` can be: - **A version string** (e.g., ``python3.8``, ``pypy3``). ``virtualenv`` will search for any interpreter that matches this version. +- **A version specifier** using PEP 440 operators (e.g., ``>=3.12``, ``~=3.11.0``, ``python>=3.10``). ``virtualenv`` will search for any interpreter that satisfies the version constraint. You can also specify the implementation: ``cpython>=3.12``. - **An absolute path** (e.g., ``/usr/bin/python3.8``). This is a *strict* requirement. Only the interpreter at this exact path will be used. If it does not exist or is not a valid interpreter, creation will fail. **``--try-first-with ``: The Hint** diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py index 1aa43786f..ae2db1d21 100644 --- a/src/virtualenv/discovery/py_info.py +++ b/src/virtualenv/discovery/py_info.py @@ -405,7 +405,7 @@ def clear_cache(cls, app_data): clear(app_data) cls._cache_exe_discovery.clear() - def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911 + def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911, PLR0912 """Check if a given specification can be satisfied by the this python interpreter instance.""" if spec.path: if self.executable == os.path.abspath(spec.path): diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py index 9ffc9a5cf..070654d3b 100644 --- a/src/virtualenv/discovery/py_spec.py +++ b/src/virtualenv/discovery/py_spec.py @@ -157,7 +157,7 @@ def generate_re(self, *, windows: bool) -> re.Pattern: def is_abs(self): return self.path is not None and os.path.isabs(self.path) - def satisfies(self, spec): + def satisfies(self, spec): # noqa: C901, PLR0911, PLR0912 """Called when there's a candidate metadata spec to see if compatible - e.g. PEP-514 on Windows.""" if spec.is_abs and self.is_abs and self.path != spec.path: return False diff --git a/tasks/update_embedded.py b/tasks/update_embedded.py index 9134c83c5..c2dc0d9bb 100755 --- a/tasks/update_embedded.py +++ b/tasks/update_embedded.py @@ -1,4 +1,4 @@ -"""Helper script to rebuild virtualenv.py from virtualenv_support.""" # noqa: EXE002 +"""Helper script to rebuild virtualenv.py from virtualenv_support.""" from __future__ import annotations From cf36c32081a1e8e8170ceceaf220d556e1fcd257 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 21:50:51 -0800 Subject: [PATCH 23/29] Update documentation --- docs/changelog/2994.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog/2994.feature.rst b/docs/changelog/2994.feature.rst index 4ef27c708..5207a2f8d 100644 --- a/docs/changelog/2994.feature.rst +++ b/docs/changelog/2994.feature.rst @@ -1 +1 @@ -Add support for PEP 440 version specifiers in the ``--python`` flag. Users can now specify Python versions using operators like ``>=``, ``<=``, ``~=``, etc. For example: ``virtualenv --python=">=3.12" myenv`` - by :user:`rahuldevikar`. +Add support for PEP 440 version specifiers in the ``--python`` flag. Users can now specify Python versions using operators like ``>=``, ``<=``, ``~=``, etc. For example: ``virtualenv --python=">=3.12" myenv`` `. \ No newline at end of file From b5045358cfcde9d9e2d060e48e1c586b814712a3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 3 Jan 2026 05:58:13 +0000 Subject: [PATCH 24/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/changelog/2994.feature.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changelog/2994.feature.rst b/docs/changelog/2994.feature.rst index 5207a2f8d..d11bc8b05 100644 --- a/docs/changelog/2994.feature.rst +++ b/docs/changelog/2994.feature.rst @@ -1 +1 @@ -Add support for PEP 440 version specifiers in the ``--python`` flag. Users can now specify Python versions using operators like ``>=``, ``<=``, ``~=``, etc. For example: ``virtualenv --python=">=3.12" myenv`` `. \ No newline at end of file +Add support for PEP 440 version specifiers in the ``--python`` flag. Users can now specify Python versions using operators like ``>=``, ``<=``, ``~=``, etc. For example: ``virtualenv --python=">=3.12" myenv`` `. From 4afaf6ce3d97724e3442a13387db845d4f37a942 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 22:12:35 -0800 Subject: [PATCH 25/29] Retrigger CI checks From 7faed93733ebf2d44370571d51c607bd5662beca Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Fri, 2 Jan 2026 23:11:32 -0800 Subject: [PATCH 26/29] revert check.yml and increase timeout --- .github/workflows/check.yaml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index c6e6ba93b..928a2679a 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -55,10 +55,6 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: 🏷️ Fetch upstream tags for versioning - shell: bash - run: | - git fetch --force --tags https://github.com/pypa/virtualenv.git - name: 🐍 Setup Python for tox uses: actions/setup-python@v5 with: @@ -67,9 +63,9 @@ jobs: shell: bash run: | if [[ "${{ matrix.py }}" == "3.13t" || "${{ matrix.py }}" == "3.14t" ]]; then - uv tool install --no-managed-python --python 3.14 "tox>=4.32" --with . + uv tool install --no-managed-python --python 3.14 tox --with . else - uv tool install --no-managed-python --python 3.14 "tox>=4.32" --with tox-uv --with . + uv tool install --no-managed-python --python 3.14 tox --with tox-uv --with . fi - name: 🐍 Setup Python for test ${{ matrix.py }} uses: actions/setup-python@v5 @@ -141,16 +137,12 @@ jobs: - name: 🚀 Install uv uses: astral-sh/setup-uv@v4 - name: 📦 Install tox - run: uv tool install --python-preference only-managed --python 3.14 "tox>=4.32" --with tox-uv + run: uv tool install --python-preference only-managed --python 3.14 tox --with tox-uv - name: 📥 Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - - name: 🏷️ Fetch upstream tags for versioning - shell: bash - run: | - git fetch --force --tags https://github.com/pypa/virtualenv.git - name: 🏗️ Setup check suite run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} - name: 🏃 Run check for ${{ matrix.tox_env }} - run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} + run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} \ No newline at end of file From 4c6a1ad1a9f9bcc13269f43bb7c5a5de658eac8a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:24:16 +0000 Subject: [PATCH 27/29] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .github/workflows/check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 48cd7fa5f..c6e6ba93b 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -153,4 +153,4 @@ jobs: - name: 🏗️ Setup check suite run: tox run -vv --notest --skip-missing-interpreters false -e ${{ matrix.tox_env }} - name: 🏃 Run check for ${{ matrix.tox_env }} - run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} \ No newline at end of file + run: tox run --skip-pkg-install -e ${{ matrix.tox_env }} From a0068108eaf4c36e3ff2aa2bf15fa17bd023dc33 Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Mon, 5 Jan 2026 15:57:22 -0800 Subject: [PATCH 28/29] Add Version Specifier class --- src/virtualenv/discovery/py_info.py | 16 +- src/virtualenv/discovery/py_spec.py | 55 +++---- src/virtualenv/util/specifier.py | 215 +++++++++++++++++++++++++++ tasks/update_embedded.py | 2 +- tests/unit/discovery/test_py_spec.py | 2 +- 5 files changed, 247 insertions(+), 43 deletions(-) create mode 100644 src/virtualenv/util/specifier.py diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py index ae2db1d21..7b53d4b60 100644 --- a/src/virtualenv/discovery/py_info.py +++ b/src/virtualenv/discovery/py_info.py @@ -19,16 +19,7 @@ from pathlib import Path from string import digits -try: - from packaging.version import Version -except ModuleNotFoundError: # pragma: no cover - fallback for non-site virtual executions - try: - site_packages = Path(__file__).resolve().parents[2] - if site_packages.exists(): - sys.path.insert(0, str(site_packages)) - from packaging.version import Version - except ModuleNotFoundError: - Version = None # Will be unavailable for version specifier matching +from virtualenv.util.specifier import SimpleVersion VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) # noqa: PYI024 LOGGER = logging.getLogger(__name__) @@ -435,8 +426,6 @@ def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911, PLR0912 return False if spec.version_specifier is not None: - if Version is None: - return False # Can't match version specifiers without packaging library version_info = self.version_info release = f"{version_info.major}.{version_info.minor}.{version_info.micro}" if version_info.releaselevel != "final": @@ -447,8 +436,7 @@ def satisfies(self, spec, impl_must_match): # noqa: C901, PLR0911, PLR0912 }.get(version_info.releaselevel) if suffix is not None: release = f"{release}{suffix}{version_info.serial}" - version = Version(release) - if not spec.version_specifier.contains(version): + if not spec.version_specifier.contains(release): return False for our, req in zip(self.version_info[0:3], (spec.major, spec.minor, spec.micro)): diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py index 070654d3b..76126b5c5 100644 --- a/src/virtualenv/discovery/py_spec.py +++ b/src/virtualenv/discovery/py_spec.py @@ -7,22 +7,7 @@ import sys from pathlib import Path -try: - from packaging.specifiers import InvalidSpecifier, SpecifierSet - from packaging.version import InvalidVersion, Version -except ModuleNotFoundError: # pragma: no cover - fallback for non-site virtual executions - try: - site_packages = Path(__file__).resolve().parents[2] - if site_packages.exists(): - sys.path.insert(0, str(site_packages)) - from packaging.specifiers import InvalidSpecifier, SpecifierSet - from packaging.version import InvalidVersion, Version - except ModuleNotFoundError: - # Packaging not available - version specifier support will be disabled - InvalidSpecifier = Exception # type: ignore[misc, assignment] - SpecifierSet = None # type: ignore[misc, assignment] - InvalidVersion = Exception # type: ignore[misc, assignment] - Version = None # type: ignore[misc, assignment] +from virtualenv.util.specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?Pt)?(?:-(?P32|64))?$") SPECIFIER_PATTERN = re.compile(r"^(?:(?P[A-Za-z]+)\s*)?(?P(?:===|==|~=|!=|<=|>=|<|>).+)$") @@ -175,24 +160,30 @@ def satisfies(self, spec): # noqa: C901, PLR0911, PLR0912 break components.append(part) if components: + version_str = ".".join(str(part) for part in components) try: - candidate = Version(".".join(str(part) for part in components)) + candidate = Version(version_str) except InvalidVersion: candidate = None else: for item in spec.version_specifier: - base_version = item.version - if base_version.endswith(".*"): - base_version = base_version[:-2] - if not base_version: - continue - try: - required_precision = len(Version(base_version).release) - except InvalidVersion: - continue + # Check precision requirements + base_version_str = item.version_str + if item.is_wildcard: + # For wildcard versions, get base precision + try: + required_precision = len(item.version.release) + except (AttributeError, ValueError): + continue + else: + try: + required_precision = len(item.version.release) + except (AttributeError, ValueError): + continue + if len(components) < required_precision: continue - if not item.contains(candidate): + if not item.contains(version_str): return False for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)): @@ -215,6 +206,16 @@ def __repr__(self) -> str: return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})" +# Create aliases for backward compatibility +SpecifierSet = SimpleSpecifierSet +Version = SimpleVersion +InvalidSpecifier = ValueError +InvalidVersion = ValueError + __all__ = [ "PythonSpec", + "SpecifierSet", + "Version", + "InvalidSpecifier", + "InvalidVersion", ] diff --git a/src/virtualenv/util/specifier.py b/src/virtualenv/util/specifier.py new file mode 100644 index 000000000..9902bb9d5 --- /dev/null +++ b/src/virtualenv/util/specifier.py @@ -0,0 +1,215 @@ +"""Version specifier support using only standard library (PEP 440 compatible).""" + +from __future__ import annotations + +import re + + +class SimpleVersion: + """Simple PEP 440-like version parser using only standard library.""" + + def __init__(self, version_str: str) -> None: + self.version_str = version_str + # Parse version string into components + # Support formats like: "3.11", "3.11.0", "3.11.0a1", "3.11.0b2", "3.11.0rc1" + match = re.match( + r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(a|b|rc)(\d+))?$", + version_str.strip(), + ) + if not match: + msg = f"Invalid version: {version_str}" + raise ValueError(msg) + + self.major = int(match.group(1)) + self.minor = int(match.group(2)) if match.group(2) else 0 + self.micro = int(match.group(3)) if match.group(3) else 0 + self.pre_type = match.group(4) # a, b, rc or None + self.pre_num = int(match.group(5)) if match.group(5) else None + self.release = (self.major, self.minor, self.micro) + + def __eq__(self, other): + if not isinstance(other, SimpleVersion): + return NotImplemented + return ( + self.release == other.release + and self.pre_type == other.pre_type + and self.pre_num == other.pre_num + ) + + def __lt__(self, other): + if not isinstance(other, SimpleVersion): + return NotImplemented + # Compare release tuples first + if self.release != other.release: + return self.release < other.release + # If releases are equal, compare pre-release + # No pre-release is greater than any pre-release + if self.pre_type is None and other.pre_type is None: + return False + if self.pre_type is None: + return False # self is final, other is pre-release + if other.pre_type is None: + return True # self is pre-release, other is final + # Both are pre-releases, compare type then number + pre_order = {"a": 1, "b": 2, "rc": 3} + if pre_order[self.pre_type] != pre_order[other.pre_type]: + return pre_order[self.pre_type] < pre_order[other.pre_type] + return (self.pre_num or 0) < (other.pre_num or 0) + + def __le__(self, other): + return self == other or self < other + + def __gt__(self, other): + if not isinstance(other, SimpleVersion): + return NotImplemented + return not self <= other + + def __ge__(self, other): + return not self < other + + def __str__(self): + return self.version_str + + def __repr__(self): + return f"SimpleVersion('{self.version_str}')" + + +class SimpleSpecifier: + """Simple PEP 440-like version specifier using only standard library.""" + + def __init__(self, spec_str: str) -> None: + self.spec_str = spec_str.strip() + # Parse operator and version + match = re.match(r"^(===|==|~=|!=|<=|>=|<|>)\s*(.+)$", self.spec_str) + if not match: + msg = f"Invalid specifier: {spec_str}" + raise ValueError(msg) + + self.operator = match.group(1) + self.version_str = match.group(2).strip() + + # Handle wildcard versions like "3.11.*" + if self.version_str.endswith(".*"): + self.is_wildcard = True + self.wildcard_version = self.version_str[:-2] + # Count the precision for wildcard matching + self.wildcard_precision = len(self.wildcard_version.split(".")) + self.version_str = self.wildcard_version + else: + self.is_wildcard = False + self.wildcard_precision = None + + try: + self.version = SimpleVersion(self.version_str) + except ValueError: + # If version parsing fails, store as string for prefix matching + self.version = None + + def contains(self, version_str: str) -> bool: + """Check if a version string satisfies this specifier.""" + try: + candidate = SimpleVersion(version_str) if isinstance(version_str, str) else version_str + except ValueError: + return False + + if self.version is None: + return False + + if self.is_wildcard: + # For wildcard specs like "3.11.*", check prefix match + if self.operator == "==": + return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision] + if self.operator == "!=": + return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision] + # Other operators with wildcards are not standard, treat as False + return False + + # Standard version comparison + if self.operator == "===": + return str(candidate) == str(self.version) + if self.operator == "==": + return candidate == self.version + if self.operator == "!=": + return candidate != self.version + if self.operator == "<": + return candidate < self.version + if self.operator == "<=": + return candidate <= self.version + if self.operator == ">": + return candidate > self.version + if self.operator == ">=": + return candidate >= self.version + if self.operator == "~=": + # Compatible release: ~=3.11.0 matches >=3.11.0, <3.12.0 + # ~=3.11 matches >=3.11, <4.0 + if candidate < self.version: + return False + # Calculate upper bound based on precision + if len(self.version.release) >= 2: # noqa: PLR2004 + # For ~=3.11.0 -> upper is 3.12 + # For ~=3.11 -> upper is 4.0 + upper_parts = list(self.version.release[:-1]) + upper_parts[-1] += 1 + upper = SimpleVersion(".".join(str(p) for p in upper_parts)) + return candidate < upper + return True + + return False + + def __eq__(self, other): + if not isinstance(other, SimpleSpecifier): + return NotImplemented + return self.spec_str == other.spec_str + + def __str__(self): + return self.spec_str + + def __repr__(self): + return f"SimpleSpecifier('{self.spec_str}')" + + +class SimpleSpecifierSet: + """Simple PEP 440-like specifier set using only standard library.""" + + def __init__(self, specifiers_str: str = "") -> None: + self.specifiers_str = specifiers_str.strip() + self.specifiers = [] + + if self.specifiers_str: + # Split by comma for compound specifiers + for spec_str in self.specifiers_str.split(","): + spec_str = spec_str.strip() + if spec_str: + try: + self.specifiers.append(SimpleSpecifier(spec_str)) + except ValueError: + # Invalid specifier, skip it + pass + + def contains(self, version_str: str) -> bool: + """Check if a version satisfies all specifiers in the set.""" + if not self.specifiers: + return True + # All specifiers must be satisfied + return all(spec.contains(version_str) for spec in self.specifiers) + + def __iter__(self): + return iter(self.specifiers) + + def __eq__(self, other): + if not isinstance(other, SimpleSpecifierSet): + return NotImplemented + return self.specifiers_str == other.specifiers_str + + def __str__(self): + return self.specifiers_str + + def __repr__(self): + return f"SimpleSpecifierSet('{self.specifiers_str}')" + + +__all__ = [ + "SimpleVersion", + "SimpleSpecifier", + "SimpleSpecifierSet", +] diff --git a/tasks/update_embedded.py b/tasks/update_embedded.py index c2dc0d9bb..9134c83c5 100755 --- a/tasks/update_embedded.py +++ b/tasks/update_embedded.py @@ -1,4 +1,4 @@ -"""Helper script to rebuild virtualenv.py from virtualenv_support.""" +"""Helper script to rebuild virtualenv.py from virtualenv_support.""" # noqa: EXE002 from __future__ import annotations diff --git a/tests/unit/discovery/test_py_spec.py b/tests/unit/discovery/test_py_spec.py index dac91fda8..2c037b6bb 100644 --- a/tests/unit/discovery/test_py_spec.py +++ b/tests/unit/discovery/test_py_spec.py @@ -4,9 +4,9 @@ from copy import copy import pytest -from packaging.specifiers import SpecifierSet from virtualenv.discovery.py_spec import PythonSpec +from virtualenv.util.specifier import SimpleSpecifierSet as SpecifierSet def test_bad_py_spec(): From 66f700a7b4355e9e3ff13ba542f0202a1cb2f24e Mon Sep 17 00:00:00 2001 From: Rahul Devikar Date: Mon, 5 Jan 2026 16:11:47 -0800 Subject: [PATCH 29/29] Break into multiple methods --- src/virtualenv/discovery/py_info.py | 3 - src/virtualenv/discovery/py_spec.py | 74 +++++++++-------- src/virtualenv/util/specifier.py | 118 +++++++++++++++++----------- 3 files changed, 106 insertions(+), 89 deletions(-) diff --git a/src/virtualenv/discovery/py_info.py b/src/virtualenv/discovery/py_info.py index 7b53d4b60..e72c82dd6 100644 --- a/src/virtualenv/discovery/py_info.py +++ b/src/virtualenv/discovery/py_info.py @@ -16,11 +16,8 @@ import sysconfig import warnings from collections import OrderedDict, namedtuple -from pathlib import Path from string import digits -from virtualenv.util.specifier import SimpleVersion - VersionInfo = namedtuple("VersionInfo", ["major", "minor", "micro", "releaselevel", "serial"]) # noqa: PYI024 LOGGER = logging.getLogger(__name__) diff --git a/src/virtualenv/discovery/py_spec.py b/src/virtualenv/discovery/py_spec.py index 76126b5c5..4bd8b4209 100644 --- a/src/virtualenv/discovery/py_spec.py +++ b/src/virtualenv/discovery/py_spec.py @@ -2,12 +2,11 @@ from __future__ import annotations +import contextlib import os import re -import sys -from pathlib import Path -from virtualenv.util.specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion +from virtualenv.util.specifier import SimpleSpecifierSet, SimpleVersion PATTERN = re.compile(r"^(?P[a-zA-Z]+)?(?P[0-9.]+)?(?Pt)?(?:-(?P32|64))?$") SPECIFIER_PATTERN = re.compile(r"^(?:(?P[A-Za-z]+)\s*)?(?P(?:===|==|~=|!=|<=|>=|<|>).+)$") @@ -142,7 +141,36 @@ def generate_re(self, *, windows: bool) -> re.Pattern: def is_abs(self): return self.path is not None and os.path.isabs(self.path) - def satisfies(self, spec): # noqa: C901, PLR0911, PLR0912 + def _check_version_specifier(self, spec): + """Check if version specifier is satisfied.""" + components: list[int] = [] + for part in (self.major, self.minor, self.micro): + if part is None: + break + components.append(part) + if not components: + return True + + version_str = ".".join(str(part) for part in components) + with contextlib.suppress(InvalidVersion): + Version(version_str) + for item in spec.version_specifier: + # Check precision requirements + required_precision = self._get_required_precision(item) + if required_precision is None or len(components) < required_precision: + continue + if not item.contains(version_str): + return False + return True + + @staticmethod + def _get_required_precision(item): + """Get the required precision for a specifier item.""" + with contextlib.suppress(AttributeError, ValueError): + return len(item.version.release) + return None + + def satisfies(self, spec): # noqa: PLR0911 """Called when there's a candidate metadata spec to see if compatible - e.g. PEP-514 on Windows.""" if spec.is_abs and self.is_abs and self.path != spec.path: return False @@ -153,38 +181,8 @@ def satisfies(self, spec): # noqa: C901, PLR0911, PLR0912 if spec.free_threaded is not None and spec.free_threaded != self.free_threaded: return False - if spec.version_specifier is not None: - components: list[int] = [] - for part in (self.major, self.minor, self.micro): - if part is None: - break - components.append(part) - if components: - version_str = ".".join(str(part) for part in components) - try: - candidate = Version(version_str) - except InvalidVersion: - candidate = None - else: - for item in spec.version_specifier: - # Check precision requirements - base_version_str = item.version_str - if item.is_wildcard: - # For wildcard versions, get base precision - try: - required_precision = len(item.version.release) - except (AttributeError, ValueError): - continue - else: - try: - required_precision = len(item.version.release) - except (AttributeError, ValueError): - continue - - if len(components) < required_precision: - continue - if not item.contains(version_str): - return False + if spec.version_specifier is not None and not self._check_version_specifier(spec): + return False for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro)): if req is not None and our is not None and our != req: @@ -213,9 +211,9 @@ def __repr__(self) -> str: InvalidVersion = ValueError __all__ = [ + "InvalidSpecifier", + "InvalidVersion", "PythonSpec", "SpecifierSet", "Version", - "InvalidSpecifier", - "InvalidVersion", ] diff --git a/src/virtualenv/util/specifier.py b/src/virtualenv/util/specifier.py index 9902bb9d5..eca5b3389 100644 --- a/src/virtualenv/util/specifier.py +++ b/src/virtualenv/util/specifier.py @@ -2,6 +2,8 @@ from __future__ import annotations +import contextlib +import operator import re @@ -30,11 +32,10 @@ def __init__(self, version_str: str) -> None: def __eq__(self, other): if not isinstance(other, SimpleVersion): return NotImplemented - return ( - self.release == other.release - and self.pre_type == other.pre_type - and self.pre_num == other.pre_num - ) + return self.release == other.release and self.pre_type == other.pre_type and self.pre_num == other.pre_num + + def __hash__(self): + return hash((self.release, self.pre_type, self.pre_num)) def __lt__(self, other): if not isinstance(other, SimpleVersion): @@ -42,6 +43,10 @@ def __lt__(self, other): # Compare release tuples first if self.release != other.release: return self.release < other.release + return self._compare_prerelease(other) + + def _compare_prerelease(self, other): + """Compare pre-release versions.""" # If releases are equal, compare pre-release # No pre-release is greater than any pre-release if self.pre_type is None and other.pre_type is None: @@ -77,6 +82,16 @@ def __repr__(self): class SimpleSpecifier: """Simple PEP 440-like version specifier using only standard library.""" + __slots__ = ( + "is_wildcard", + "operator", + "spec_str", + "version", + "version_str", + "wildcard_precision", + "wildcard_version", + ) + def __init__(self, spec_str: str) -> None: self.spec_str = spec_str.strip() # Parse operator and version @@ -116,51 +131,56 @@ def contains(self, version_str: str) -> bool: return False if self.is_wildcard: - # For wildcard specs like "3.11.*", check prefix match - if self.operator == "==": - return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision] - if self.operator == "!=": - return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision] - # Other operators with wildcards are not standard, treat as False - return False + return self._check_wildcard(candidate) + return self._check_standard(candidate) - # Standard version comparison - if self.operator == "===": - return str(candidate) == str(self.version) + def _check_wildcard(self, candidate): + """Check wildcard version matching.""" if self.operator == "==": - return candidate == self.version + return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision] if self.operator == "!=": - return candidate != self.version - if self.operator == "<": - return candidate < self.version - if self.operator == "<=": - return candidate <= self.version - if self.operator == ">": - return candidate > self.version - if self.operator == ">=": - return candidate >= self.version - if self.operator == "~=": - # Compatible release: ~=3.11.0 matches >=3.11.0, <3.12.0 - # ~=3.11 matches >=3.11, <4.0 - if candidate < self.version: - return False - # Calculate upper bound based on precision - if len(self.version.release) >= 2: # noqa: PLR2004 - # For ~=3.11.0 -> upper is 3.12 - # For ~=3.11 -> upper is 4.0 - upper_parts = list(self.version.release[:-1]) - upper_parts[-1] += 1 - upper = SimpleVersion(".".join(str(p) for p in upper_parts)) - return candidate < upper - return True + return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision] + # Other operators with wildcards are not standard + return False + def _check_standard(self, candidate): + """Check standard version comparisons.""" + if self.operator == "===": + return str(candidate) == str(self.version) + if self.operator == "~=": + return self._check_compatible_release(candidate) + # Use operator module for comparisons + cmp_ops = { + "==": operator.eq, + "!=": operator.ne, + "<": operator.lt, + "<=": operator.le, + ">": operator.gt, + ">=": operator.ge, + } + if self.operator in cmp_ops: + return cmp_ops[self.operator](candidate, self.version) return False + def _check_compatible_release(self, candidate): + """Check compatible release version (~=).""" + if candidate < self.version: + return False + if len(self.version.release) >= 2: # noqa: PLR2004 + upper_parts = list(self.version.release[:-1]) + upper_parts[-1] += 1 + upper = SimpleVersion(".".join(str(p) for p in upper_parts)) + return candidate < upper + return True + def __eq__(self, other): if not isinstance(other, SimpleSpecifier): return NotImplemented return self.spec_str == other.spec_str + def __hash__(self): + return hash(self.spec_str) + def __str__(self): return self.spec_str @@ -171,20 +191,19 @@ def __repr__(self): class SimpleSpecifierSet: """Simple PEP 440-like specifier set using only standard library.""" + __slots__ = ("specifiers", "specifiers_str") + def __init__(self, specifiers_str: str = "") -> None: self.specifiers_str = specifiers_str.strip() self.specifiers = [] if self.specifiers_str: # Split by comma for compound specifiers - for spec_str in self.specifiers_str.split(","): - spec_str = spec_str.strip() - if spec_str: - try: - self.specifiers.append(SimpleSpecifier(spec_str)) - except ValueError: - # Invalid specifier, skip it - pass + for spec_item in self.specifiers_str.split(","): + stripped = spec_item.strip() + if stripped: + with contextlib.suppress(ValueError): + self.specifiers.append(SimpleSpecifier(stripped)) def contains(self, version_str: str) -> bool: """Check if a version satisfies all specifiers in the set.""" @@ -201,6 +220,9 @@ def __eq__(self, other): return NotImplemented return self.specifiers_str == other.specifiers_str + def __hash__(self): + return hash(self.specifiers_str) + def __str__(self): return self.specifiers_str @@ -209,7 +231,7 @@ def __repr__(self): __all__ = [ - "SimpleVersion", "SimpleSpecifier", "SimpleSpecifierSet", + "SimpleVersion", ]