Compare commits

..

No commits in common. "main" and "0.7.14" have entirely different histories.
main ... 0.7.14

199 changed files with 3262 additions and 11232 deletions

View file

@ -1,4 +1,4 @@
[profile.default] [profile.default]
# Mark tests that take longer than 10s as slow. # Mark tests that take longer than 10s as slow.
# Terminate after 120s as a stop-gap measure to terminate on deadlock. # Terminate after 90s as a stop-gap measure to terminate on deadlock.
slow-timeout = { period = "10s", terminate-after = 12 } slow-timeout = { period = "10s", terminate-after = 9 }

View file

@ -54,7 +54,7 @@ jobs:
- name: "Prep README.md" - name: "Prep README.md"
run: python scripts/transform_readme.py --target pypi run: python scripts/transform_readme.py --target pypi
- name: "Build sdist" - name: "Build sdist"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
command: sdist command: sdist
args: --out dist args: --out dist
@ -74,7 +74,7 @@ jobs:
# uv-build # uv-build
- name: "Build sdist uv-build" - name: "Build sdist uv-build"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
command: sdist command: sdist
args: --out crates/uv-build/dist -m crates/uv-build/Cargo.toml args: --out crates/uv-build/dist -m crates/uv-build/Cargo.toml
@ -103,7 +103,7 @@ jobs:
# uv # uv
- name: "Build wheels - x86_64" - name: "Build wheels - x86_64"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: x86_64 target: x86_64
args: --release --locked --out dist --features self-update args: --release --locked --out dist --features self-update
@ -133,7 +133,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels uv-build - x86_64" - name: "Build wheels uv-build - x86_64"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: x86_64 target: x86_64
args: --profile minimal-size --locked --out crates/uv-build/dist -m crates/uv-build/Cargo.toml args: --profile minimal-size --locked --out crates/uv-build/dist -m crates/uv-build/Cargo.toml
@ -157,7 +157,7 @@ jobs:
# uv # uv
- name: "Build wheels - aarch64" - name: "Build wheels - aarch64"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: aarch64 target: aarch64
args: --release --locked --out dist --features self-update args: --release --locked --out dist --features self-update
@ -193,7 +193,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels uv-build - aarch64" - name: "Build wheels uv-build - aarch64"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: aarch64 target: aarch64
args: --profile minimal-size --locked --out crates/uv-build/dist -m crates/uv-build/Cargo.toml args: --profile minimal-size --locked --out crates/uv-build/dist -m crates/uv-build/Cargo.toml
@ -231,7 +231,7 @@ jobs:
# uv # uv
- name: "Build wheels" - name: "Build wheels"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
args: --release --locked --out dist --features self-update,windows-gui-bin args: --release --locked --out dist --features self-update,windows-gui-bin
@ -267,7 +267,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels uv-build" - name: "Build wheels uv-build"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
args: --profile minimal-size --locked --out crates/uv-build/dist -m crates/uv-build/Cargo.toml args: --profile minimal-size --locked --out crates/uv-build/dist -m crates/uv-build/Cargo.toml
@ -303,7 +303,7 @@ jobs:
# uv # uv
- name: "Build wheels" - name: "Build wheels"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
# Generally, we try to build in a target docker container. In this case however, a # Generally, we try to build in a target docker container. In this case however, a
@ -368,7 +368,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels uv-build" - name: "Build wheels uv-build"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
manylinux: auto manylinux: auto
@ -412,7 +412,7 @@ jobs:
# uv # uv
- name: "Build wheels" - name: "Build wheels"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
# On `aarch64`, use `manylinux: 2_28`; otherwise, use `manylinux: auto`. # On `aarch64`, use `manylinux: 2_28`; otherwise, use `manylinux: auto`.
@ -461,7 +461,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels uv-build" - name: "Build wheels uv-build"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
# On `aarch64`, use `manylinux: 2_28`; otherwise, use `manylinux: auto`. # On `aarch64`, use `manylinux: 2_28`; otherwise, use `manylinux: auto`.
@ -509,7 +509,7 @@ jobs:
# uv # uv
- name: "Build wheels" - name: "Build wheels"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
manylinux: auto manylinux: auto
@ -561,7 +561,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels uv-build" - name: "Build wheels uv-build"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
manylinux: auto manylinux: auto
@ -614,7 +614,7 @@ jobs:
# uv # uv
- name: "Build wheels" - name: "Build wheels"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
manylinux: auto manylinux: auto
@ -671,7 +671,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels uv-build" - name: "Build wheels uv-build"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
manylinux: auto manylinux: auto
@ -712,7 +712,7 @@ jobs:
# uv # uv
- name: "Build wheels" - name: "Build wheels"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
manylinux: auto manylinux: auto
@ -761,7 +761,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels uv-build" - name: "Build wheels uv-build"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
manylinux: auto manylinux: auto
@ -807,7 +807,7 @@ jobs:
# uv # uv
- name: "Build wheels" - name: "Build wheels"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
manylinux: musllinux_1_1 manylinux: musllinux_1_1
@ -854,7 +854,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels uv-build" - name: "Build wheels uv-build"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.target }} target: ${{ matrix.target }}
manylinux: musllinux_1_1 manylinux: musllinux_1_1
@ -901,7 +901,7 @@ jobs:
# uv # uv
- name: "Build wheels" - name: "Build wheels"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
manylinux: musllinux_1_1 manylinux: musllinux_1_1
@ -966,7 +966,7 @@ jobs:
# uv-build # uv-build
- name: "Build wheels" - name: "Build wheels"
uses: PyO3/maturin-action@e10f6c464b90acceb5f640d31beda6d586ba7b4a # v1.49.3 uses: PyO3/maturin-action@44479ae1b6b1a57f561e03add8832e62c185eb17 # v1.48.1
with: with:
target: ${{ matrix.platform.target }} target: ${{ matrix.platform.target }}
manylinux: musllinux_1_1 manylinux: musllinux_1_1

View file

@ -45,7 +45,6 @@ jobs:
name: plan name: plan
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
login: ${{ steps.plan.outputs.login }}
push: ${{ steps.plan.outputs.push }} push: ${{ steps.plan.outputs.push }}
tag: ${{ steps.plan.outputs.tag }} tag: ${{ steps.plan.outputs.tag }}
action: ${{ steps.plan.outputs.action }} action: ${{ steps.plan.outputs.action }}
@ -54,16 +53,13 @@ jobs:
env: env:
DRY_RUN: ${{ inputs.plan == '' || fromJson(inputs.plan).announcement_tag_is_implicit }} DRY_RUN: ${{ inputs.plan == '' || fromJson(inputs.plan).announcement_tag_is_implicit }}
TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag }} TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag }}
IS_LOCAL_PR: ${{ github.event.pull_request.head.repo.full_name == 'astral-sh/uv' }}
id: plan id: plan
run: | run: |
if [ "${{ env.DRY_RUN }}" == "false" ]; then if [ "${{ env.DRY_RUN }}" == "false" ]; then
echo "login=true" >> "$GITHUB_OUTPUT"
echo "push=true" >> "$GITHUB_OUTPUT" echo "push=true" >> "$GITHUB_OUTPUT"
echo "tag=${{ env.TAG }}" >> "$GITHUB_OUTPUT" echo "tag=${{ env.TAG }}" >> "$GITHUB_OUTPUT"
echo "action=build and publish" >> "$GITHUB_OUTPUT" echo "action=build and publish" >> "$GITHUB_OUTPUT"
else else
echo "login=${{ env.IS_LOCAL_PR }}" >> "$GITHUB_OUTPUT"
echo "push=false" >> "$GITHUB_OUTPUT" echo "push=false" >> "$GITHUB_OUTPUT"
echo "tag=dry-run" >> "$GITHUB_OUTPUT" echo "tag=dry-run" >> "$GITHUB_OUTPUT"
echo "action=build" >> "$GITHUB_OUTPUT" echo "action=build" >> "$GITHUB_OUTPUT"
@ -94,7 +90,6 @@ jobs:
# Login to DockerHub (when not pushing, it's to avoid rate-limiting) # Login to DockerHub (when not pushing, it's to avoid rate-limiting)
- uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
if: ${{ needs.docker-plan.outputs.login == 'true' }}
with: with:
username: ${{ needs.docker-plan.outputs.push == 'true' && 'astral' || 'astralshbot' }} username: ${{ needs.docker-plan.outputs.push == 'true' && 'astral' || 'astralshbot' }}
password: ${{ needs.docker-plan.outputs.push == 'true' && secrets.DOCKERHUB_TOKEN_RW || secrets.DOCKERHUB_TOKEN_RO }} password: ${{ needs.docker-plan.outputs.push == 'true' && secrets.DOCKERHUB_TOKEN_RW || secrets.DOCKERHUB_TOKEN_RO }}
@ -137,7 +132,7 @@ jobs:
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: depot/build-push-action@2583627a84956d07561420dcc1d0eb1f2af3fac0 # v1.15.0 uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with: with:
project: 7hd4vdzmw5 # astral-sh/uv project: 7hd4vdzmw5 # astral-sh/uv
context: . context: .
@ -200,7 +195,6 @@ jobs:
steps: steps:
# Login to DockerHub (when not pushing, it's to avoid rate-limiting) # Login to DockerHub (when not pushing, it's to avoid rate-limiting)
- uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
if: ${{ needs.docker-plan.outputs.login == 'true' }}
with: with:
username: ${{ needs.docker-plan.outputs.push == 'true' && 'astral' || 'astralshbot' }} username: ${{ needs.docker-plan.outputs.push == 'true' && 'astral' || 'astralshbot' }}
password: ${{ needs.docker-plan.outputs.push == 'true' && secrets.DOCKERHUB_TOKEN_RW || secrets.DOCKERHUB_TOKEN_RO }} password: ${{ needs.docker-plan.outputs.push == 'true' && secrets.DOCKERHUB_TOKEN_RW || secrets.DOCKERHUB_TOKEN_RO }}
@ -267,7 +261,7 @@ jobs:
- name: Build and push - name: Build and push
id: build-and-push id: build-and-push
uses: depot/build-push-action@2583627a84956d07561420dcc1d0eb1f2af3fac0 # v1.15.0 uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with: with:
context: . context: .
project: 7hd4vdzmw5 # astral-sh/uv project: 7hd4vdzmw5 # astral-sh/uv

View file

@ -82,7 +82,7 @@ jobs:
run: rustup component add rustfmt run: rustup component add rustfmt
- name: "Install uv" - name: "Install uv"
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1 uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0
- name: "rustfmt" - name: "rustfmt"
run: cargo fmt --all --check run: cargo fmt --all --check
@ -126,7 +126,7 @@ jobs:
name: "cargo clippy | ubuntu" name: "cargo clippy | ubuntu"
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
- name: "Check uv_build dependencies" - name: "Check uv_build dependencies"
@ -156,7 +156,7 @@ jobs:
run: | run: |
Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with: with:
workspaces: ${{ env.UV_WORKSPACE }} workspaces: ${{ env.UV_WORKSPACE }}
@ -175,7 +175,7 @@ jobs:
name: "cargo dev generate-all" name: "cargo dev generate-all"
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with: with:
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
- name: "Generate all" - name: "Generate all"
@ -208,12 +208,12 @@ jobs:
- uses: rui314/setup-mold@v1 - uses: rui314/setup-mold@v1
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: "Install Rust toolchain" - name: "Install Rust toolchain"
run: rustup show run: rustup show
- uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1 - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0
- name: "Install required Python versions" - name: "Install required Python versions"
run: uv python install run: uv python install
@ -223,9 +223,6 @@ jobs:
tool: cargo-nextest tool: cargo-nextest
- name: "Cargo test" - name: "Cargo test"
env:
# Retry more than default to reduce flakes in CI
UV_HTTP_RETRIES: 5
run: | run: |
cargo nextest run \ cargo nextest run \
--features python-patch \ --features python-patch \
@ -235,8 +232,7 @@ jobs:
cargo-test-macos: cargo-test-macos:
timeout-minutes: 15 timeout-minutes: 15
needs: determine_changes needs: determine_changes
# Only run macOS tests on main without opt-in if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
if: ${{ contains(github.event.pull_request.labels.*.name, 'test:macos') || github.ref == 'refs/heads/main' }}
runs-on: macos-latest-xlarge # github-macos-14-aarch64-6 runs-on: macos-latest-xlarge # github-macos-14-aarch64-6
name: "cargo test | macos" name: "cargo test | macos"
steps: steps:
@ -244,12 +240,12 @@ jobs:
- uses: rui314/setup-mold@v1 - uses: rui314/setup-mold@v1
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: "Install Rust toolchain" - name: "Install Rust toolchain"
run: rustup show run: rustup show
- uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1 - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0
- name: "Install required Python versions" - name: "Install required Python versions"
run: uv python install run: uv python install
@ -259,9 +255,6 @@ jobs:
tool: cargo-nextest tool: cargo-nextest
- name: "Cargo test" - name: "Cargo test"
env:
# Retry more than default to reduce flakes in CI
UV_HTTP_RETRIES: 5
run: | run: |
cargo nextest run \ cargo nextest run \
--no-default-features \ --no-default-features \
@ -286,11 +279,11 @@ jobs:
run: | run: |
Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse
- uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1 - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0
- name: "Install required Python versions" - name: "Install required Python versions"
run: uv python install run: uv python install
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with: with:
workspaces: ${{ env.UV_WORKSPACE }} workspaces: ${{ env.UV_WORKSPACE }}
@ -306,8 +299,6 @@ jobs:
- name: "Cargo test" - name: "Cargo test"
working-directory: ${{ env.UV_WORKSPACE }} working-directory: ${{ env.UV_WORKSPACE }}
env: env:
# Retry more than default to reduce flakes in CI
UV_HTTP_RETRIES: 5
# Avoid permission errors during concurrent tests # Avoid permission errors during concurrent tests
# See https://github.com/astral-sh/uv/issues/6940 # See https://github.com/astral-sh/uv/issues/6940
UV_LINK_MODE: copy UV_LINK_MODE: copy
@ -341,7 +332,7 @@ jobs:
run: | run: |
Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with: with:
workspaces: ${{ env.UV_WORKSPACE }}/crates/uv-trampoline workspaces: ${{ env.UV_WORKSPACE }}/crates/uv-trampoline
@ -397,7 +388,7 @@ jobs:
- name: Copy Git Repo to Dev Drive - name: Copy Git Repo to Dev Drive
run: | run: |
Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with: with:
workspaces: ${{ env.UV_WORKSPACE }}/crates/uv-trampoline workspaces: ${{ env.UV_WORKSPACE }}/crates/uv-trampoline
- name: "Install Rust toolchain" - name: "Install Rust toolchain"
@ -439,7 +430,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1 - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
- name: "Add SSH key" - name: "Add SSH key"
if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }} if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }}
@ -452,7 +443,7 @@ jobs:
- name: "Build docs (insiders)" - name: "Build docs (insiders)"
if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }} if: ${{ env.MKDOCS_INSIDERS_SSH_KEY_EXISTS == 'true' }}
run: uvx --with-requirements docs/requirements-insiders.txt mkdocs build --strict -f mkdocs.insiders.yml run: uvx --with-requirements docs/requirements.txt mkdocs build --strict -f mkdocs.insiders.yml
build-binary-linux-libc: build-binary-linux-libc:
timeout-minutes: 10 timeout-minutes: 10
@ -465,7 +456,7 @@ jobs:
- uses: rui314/setup-mold@v1 - uses: rui314/setup-mold@v1
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: "Build" - name: "Build"
run: cargo build run: cargo build
@ -479,31 +470,6 @@ jobs:
./target/debug/uvx ./target/debug/uvx
retention-days: 1 retention-days: 1
build-binary-linux-aarch64:
timeout-minutes: 10
needs: determine_changes
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-test') && (needs.determine_changes.outputs.code == 'true' || github.ref == 'refs/heads/main') }}
runs-on: github-ubuntu-24.04-aarch64-4
name: "build binary | linux aarch64"
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: rui314/setup-mold@v1
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
- name: "Build"
run: cargo build
- name: "Upload binary"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: uv-linux-aarch64-${{ github.sha }}
path: |
./target/debug/uv
./target/debug/uvx
retention-days: 1
build-binary-linux-musl: build-binary-linux-musl:
timeout-minutes: 10 timeout-minutes: 10
needs: determine_changes needs: determine_changes
@ -520,7 +486,7 @@ jobs:
sudo apt-get install musl-tools sudo apt-get install musl-tools
rustup target add x86_64-unknown-linux-musl rustup target add x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: "Build" - name: "Build"
run: cargo build --target x86_64-unknown-linux-musl --bin uv --bin uvx run: cargo build --target x86_64-unknown-linux-musl --bin uv --bin uvx
@ -545,7 +511,7 @@ jobs:
- uses: rui314/setup-mold@v1 - uses: rui314/setup-mold@v1
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: "Build" - name: "Build"
run: cargo build --bin uv --bin uvx run: cargo build --bin uv --bin uvx
@ -569,7 +535,7 @@ jobs:
- uses: rui314/setup-mold@v1 - uses: rui314/setup-mold@v1
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: "Build" - name: "Build"
run: cargo build --bin uv --bin uvx run: cargo build --bin uv --bin uvx
@ -599,7 +565,7 @@ jobs:
run: | run: |
Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with: with:
workspaces: ${{ env.UV_WORKSPACE }} workspaces: ${{ env.UV_WORKSPACE }}
@ -634,7 +600,7 @@ jobs:
run: | run: |
Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse Copy-Item -Path "${{ github.workspace }}" -Destination "${{ env.UV_WORKSPACE }}" -Recurse
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
with: with:
workspaces: ${{ env.UV_WORKSPACE }} workspaces: ${{ env.UV_WORKSPACE }}
@ -671,7 +637,7 @@ jobs:
run: rustup default ${{ steps.msrv.outputs.value }} run: rustup default ${{ steps.msrv.outputs.value }}
- name: "Install mold" - name: "Install mold"
uses: rui314/setup-mold@v1 uses: rui314/setup-mold@v1
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- run: cargo +${{ steps.msrv.outputs.value }} build - run: cargo +${{ steps.msrv.outputs.value }} build
- run: ./target/debug/uv --version - run: ./target/debug/uv --version
@ -684,7 +650,7 @@ jobs:
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: "Cross build" - name: "Cross build"
run: | run: |
# Install cross from `freebsd-firecracker` # Install cross from `freebsd-firecracker`
@ -695,7 +661,7 @@ jobs:
cross build --target x86_64-unknown-freebsd cross build --target x86_64-unknown-freebsd
- name: Test in Firecracker VM - name: Test in Firecracker VM
uses: acj/freebsd-firecracker-action@136ca0bce2adade21e526ceb07db643ad23dd2dd # v0.5.1 uses: acj/freebsd-firecracker-action@6c57bda7113c2f137ef00d54512d61ae9d64365b # v0.5.0
with: with:
verbose: false verbose: false
checkout: false checkout: false
@ -804,33 +770,6 @@ jobs:
eval "$(./uv generate-shell-completion bash)" eval "$(./uv generate-shell-completion bash)"
eval "$(./uvx --generate-shell-completion bash)" eval "$(./uvx --generate-shell-completion bash)"
smoke-test-linux-aarch64:
timeout-minutes: 10
needs: build-binary-linux-aarch64
name: "smoke test | linux aarch64"
runs-on: github-ubuntu-24.04-aarch64-2
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: "Download binary"
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: uv-linux-aarch64-${{ github.sha }}
- name: "Prepare binary"
run: |
chmod +x ./uv
chmod +x ./uvx
- name: "Smoke test"
run: |
./uv run scripts/smoke-test
- name: "Test shell completions"
run: |
eval "$(./uv generate-shell-completion bash)"
eval "$(./uvx --generate-shell-completion bash)"
smoke-test-linux-musl: smoke-test-linux-musl:
timeout-minutes: 10 timeout-minutes: 10
needs: build-binary-linux-musl needs: build-binary-linux-musl
@ -913,7 +852,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
needs: build-binary-windows-aarch64 needs: build-binary-windows-aarch64
name: "smoke test | windows aarch64" name: "smoke test | windows aarch64"
runs-on: windows-11-arm runs-on: github-windows-11-aarch64-4
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -1061,96 +1000,6 @@ jobs:
./uv run python -c "" ./uv run python -c ""
./uv run -p 3.13t python -c "" ./uv run -p 3.13t python -c ""
integration-test-windows-aarch64-implicit:
timeout-minutes: 10
needs: build-binary-windows-aarch64
name: "integration test | aarch64 windows implicit"
runs-on: windows-11-arm
steps:
- name: "Download binary"
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: uv-windows-aarch64-${{ github.sha }}
- name: "Install Python via uv (implicitly select x64)"
run: |
./uv python install -v 3.13
- name: "Create a virtual environment (stdlib)"
run: |
& (./uv python find 3.13) -m venv .venv
- name: "Check version (stdlib)"
run: |
.venv/Scripts/python --version
- name: "Create a virtual environment (uv)"
run: |
./uv venv -p 3.13 --managed-python
- name: "Check version (uv)"
run: |
.venv/Scripts/python --version
- name: "Check is x64"
run: |
.venv/Scripts/python -c "import sys; exit(1) if 'AMD64' not in sys.version else exit(0)"
- name: "Check install"
run: |
./uv pip install -v anyio
- name: "Check uv run"
run: |
./uv run python -c ""
./uv run -p 3.13 python -c ""
integration-test-windows-aarch64-explicit:
timeout-minutes: 10
needs: build-binary-windows-aarch64
name: "integration test | aarch64 windows explicit"
runs-on: windows-11-arm
steps:
- name: "Download binary"
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: uv-windows-aarch64-${{ github.sha }}
- name: "Install Python via uv (explicitly select aarch64)"
run: |
./uv python install -v cpython-3.13-windows-aarch64-none
- name: "Create a virtual environment (stdlib)"
run: |
& (./uv python find 3.13) -m venv .venv
- name: "Check version (stdlib)"
run: |
.venv/Scripts/python --version
- name: "Create a virtual environment (uv)"
run: |
./uv venv -p 3.13 --managed-python
- name: "Check version (uv)"
run: |
.venv/Scripts/python --version
- name: "Check is NOT x64"
run: |
.venv/Scripts/python -c "import sys; exit(1) if 'AMD64' in sys.version else exit(0)"
- name: "Check install"
run: |
./uv pip install -v anyio
- name: "Check uv run"
run: |
./uv run python -c ""
./uv run -p 3.13 python -c ""
integration-test-pypy-linux: integration-test-pypy-linux:
timeout-minutes: 10 timeout-minutes: 10
needs: build-binary-linux-libc needs: build-binary-linux-libc
@ -1594,7 +1443,7 @@ jobs:
run: chmod +x ./uv run: chmod +x ./uv
- name: "Configure AWS credentials" - name: "Configure AWS credentials"
uses: aws-actions/configure-aws-credentials@f503a1870408dcf2c35d5c2b8a68e69211042c7d uses: aws-actions/configure-aws-credentials@3bb878b6ab43ba8717918141cd07a0ea68cfe7ea
with: with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@ -2223,7 +2072,7 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
needs: build-binary-windows-aarch64 needs: build-binary-windows-aarch64
name: "check system | x86-64 python3.13 on windows aarch64" name: "check system | x86-64 python3.13 on windows aarch64"
runs-on: windows-11-arm runs-on: github-windows-11-aarch64-4
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -2241,28 +2090,6 @@ jobs:
- name: "Validate global Python install" - name: "Validate global Python install"
run: py -3.13 ./scripts/check_system_python.py --uv ./uv.exe run: py -3.13 ./scripts/check_system_python.py --uv ./uv.exe
system-test-windows-aarch64-aarch64-python-313:
timeout-minutes: 10
needs: build-binary-windows-aarch64
name: "check system | aarch64 python3.13 on windows aarch64"
runs-on: windows-11-arm
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.13"
architecture: "arm64"
allow-prereleases: true
- name: "Download binary"
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: uv-windows-aarch64-${{ github.sha }}
- name: "Validate global Python install"
run: py -3.13 ./scripts/check_system_python.py --uv ./uv.exe
# Test our PEP 514 integration that installs Python into the Windows registry. # Test our PEP 514 integration that installs Python into the Windows registry.
system-test-windows-registry: system-test-windows-registry:
timeout-minutes: 10 timeout-minutes: 10
@ -2510,7 +2337,7 @@ jobs:
- name: "Checkout Branch" - name: "Checkout Branch"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: "Install Rust toolchain" - name: "Install Rust toolchain"
run: rustup show run: rustup show
@ -2547,7 +2374,7 @@ jobs:
- name: "Checkout Branch" - name: "Checkout Branch"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 - uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
- name: "Install Rust toolchain" - name: "Install Rust toolchain"
run: rustup show run: rustup show

View file

@ -22,7 +22,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Install uv" - name: "Install uv"
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1 uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with: with:
pattern: wheels_uv-* pattern: wheels_uv-*
@ -43,7 +43,7 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Install uv" - name: "Install uv"
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1 uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with: with:
pattern: wheels_uv_build-* pattern: wheels_uv_build-*

View file

@ -85,6 +85,7 @@ Write-Output `
"DEV_DRIVE=$($Drive)" ` "DEV_DRIVE=$($Drive)" `
"TMP=$($Tmp)" ` "TMP=$($Tmp)" `
"TEMP=$($Tmp)" ` "TEMP=$($Tmp)" `
"UV_INTERNAL__TEST_DIR=$($Tmp)" `
"RUSTUP_HOME=$($Drive)/.rustup" ` "RUSTUP_HOME=$($Drive)/.rustup" `
"CARGO_HOME=$($Drive)/.cargo" ` "CARGO_HOME=$($Drive)/.cargo" `
"UV_WORKSPACE=$($Drive)/uv" ` "UV_WORKSPACE=$($Drive)/uv" `

View file

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1 - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0
with: with:
version: "latest" version: "latest"
enable-cache: true enable-cache: true

View file

@ -12,7 +12,7 @@ repos:
- id: validate-pyproject - id: validate-pyproject
- repo: https://github.com/crate-ci/typos - repo: https://github.com/crate-ci/typos
rev: v1.34.0 rev: v1.33.1
hooks: hooks:
- id: typos - id: typos
@ -42,7 +42,7 @@ repos:
types_or: [yaml, json5] types_or: [yaml, json5]
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.2 rev: v0.11.13
hooks: hooks:
- id: ruff-format - id: ruff-format
- id: ruff - id: ruff

View file

@ -3,182 +3,6 @@
<!-- prettier-ignore-start --> <!-- prettier-ignore-start -->
## 0.7.20
### Python
- Add Python 3.14.0b4
- Add zstd support to Python 3.14 on Unix (it already was available on Windows)
- Add PyPy 7.3.20 (for Python 3.11.13)
See the [PyPy](https://pypy.org/posts/2025/07/pypy-v7320-release.html) and [`python-build-standalone`](https://github.com/astral-sh/python-build-standalone/releases/tag/20250708) release notes for more details.
### Enhancements
- Add `--workspace` flag to `uv add` ([#14496](https://github.com/astral-sh/uv/pull/14496))
- Add auto-detection for Intel GPUs ([#14386](https://github.com/astral-sh/uv/pull/14386))
- Drop trailing arguments when writing shebangs ([#14519](https://github.com/astral-sh/uv/pull/14519))
- Add debug message when skipping Python downloads ([#14509](https://github.com/astral-sh/uv/pull/14509))
- Add support for declaring multiple modules in namespace packages ([#14460](https://github.com/astral-sh/uv/pull/14460))
### Bug fixes
- Revert normalization of trailing slashes on index URLs ([#14511](https://github.com/astral-sh/uv/pull/14511))
- Fix forced resolution with all extras in `uv version` ([#14434](https://github.com/astral-sh/uv/pull/14434))
- Fix handling of pre-releases in preferences ([#14498](https://github.com/astral-sh/uv/pull/14498))
- Remove transparent variants in `uv-extract` to enable retries ([#14450](https://github.com/astral-sh/uv/pull/14450))
### Rust API
- Add method to get packages involved in a `NoSolutionError` ([#14457](https://github.com/astral-sh/uv/pull/14457))
- Make `ErrorTree` for `NoSolutionError` public ([#14444](https://github.com/astral-sh/uv/pull/14444))
### Documentation
- Finish incomplete sentence in pip migration guide ([#14432](https://github.com/astral-sh/uv/pull/14432))
- Remove `cache-dependency-glob` examples for `setup-uv` ([#14493](https://github.com/astral-sh/uv/pull/14493))
- Remove `uv pip sync` suggestion with `pyproject.toml` ([#14510](https://github.com/astral-sh/uv/pull/14510))
- Update documentation for GitHub to use `setup-uv@v6` ([#14490](https://github.com/astral-sh/uv/pull/14490))
## 0.7.19
The **[uv build backend](https://docs.astral.sh/uv/concepts/build-backend/) is now stable**, and considered ready for production use.
The uv build backend is a great choice for pure Python projects. It has reasonable defaults, with the goal of requiring zero configuration for most users, but provides flexible configuration to accommodate most Python project structures. It integrates tightly with uv, to improve messaging and user experience. It validates project metadata and structures, preventing common mistakes. And, finally, it's very fast — `uv sync` on a new project (from `uv init`) is 10-30x faster than with other build backends.
To use uv as a build backend in an existing project, add `uv_build` to the `[build-system]` section in your `pyproject.toml`:
```toml
[build-system]
requires = ["uv_build>=0.7.19,<0.8.0"]
build-backend = "uv_build"
```
In a future release, it will replace `hatchling` as the default in `uv init`. As before, uv will remain compatible with all standards-compliant build backends.
### Python
- Add PGO distributions of Python for aarch64 Linux, which are more optimized for better performance
See the [python-build-standalone release](https://github.com/astral-sh/python-build-standalone/releases/tag/20250702) for more details.
### Enhancements
- Ignore Python patch version for `--universal` pip compile ([#14405](https://github.com/astral-sh/uv/pull/14405))
- Update the tilde version specifier warning to include more context ([#14335](https://github.com/astral-sh/uv/pull/14335))
- Clarify behavior and hint on tool install when no executables are available ([#14423](https://github.com/astral-sh/uv/pull/14423))
### Bug fixes
- Make project and interpreter lock acquisition non-fatal ([#14404](https://github.com/astral-sh/uv/pull/14404))
- Includes `sys.prefix` in cached environment keys to avoid `--with` collisions across projects ([#14403](https://github.com/astral-sh/uv/pull/14403))
### Documentation
- Add a migration guide from pip to uv projects ([#12382](https://github.com/astral-sh/uv/pull/12382))
## 0.7.18
### Python
- Added arm64 Windows Python 3.11, 3.12, 3.13, and 3.14
These are not downloaded by default, since x86-64 Python has broader ecosystem support on Windows.
However, they can be requested with `cpython-<version>-windows-aarch64`.
See the [python-build-standalone release](https://github.com/astral-sh/python-build-standalone/releases/tag/20250630) for more details.
### Enhancements
- Keep track of retries in `ManagedPythonDownload::fetch_with_retry` ([#14378](https://github.com/astral-sh/uv/pull/14378))
- Reuse build (virtual) environments across resolution and installation ([#14338](https://github.com/astral-sh/uv/pull/14338))
- Improve trace message for cached Python interpreter query ([#14328](https://github.com/astral-sh/uv/pull/14328))
- Use parsed URLs for conflicting URL error message ([#14380](https://github.com/astral-sh/uv/pull/14380))
### Preview features
- Ignore invalid build backend settings when not building ([#14372](https://github.com/astral-sh/uv/pull/14372))
### Bug fixes
- Fix equals-star and tilde-equals with `python_version` and `python_full_version` ([#14271](https://github.com/astral-sh/uv/pull/14271))
- Include the canonical path in the interpreter query cache key ([#14331](https://github.com/astral-sh/uv/pull/14331))
- Only drop build directories on program exit ([#14304](https://github.com/astral-sh/uv/pull/14304))
- Error instead of panic on conflict between global and subcommand flags ([#14368](https://github.com/astral-sh/uv/pull/14368))
- Consistently normalize trailing slashes on URLs with no path segments ([#14349](https://github.com/astral-sh/uv/pull/14349))
### Documentation
- Add instructions for publishing to JFrog's Artifactory ([#14253](https://github.com/astral-sh/uv/pull/14253))
- Edits to the build backend documentation ([#14376](https://github.com/astral-sh/uv/pull/14376))
## 0.7.17
### Bug fixes
- Apply build constraints when resolving `--with` dependencies ([#14340](https://github.com/astral-sh/uv/pull/14340))
- Drop trailing slashes when converting index URL from URL ([#14346](https://github.com/astral-sh/uv/pull/14346))
- Ignore `UV_PYTHON_CACHE_DIR` when empty ([#14336](https://github.com/astral-sh/uv/pull/14336))
- Fix error message ordering for `pyvenv.cfg` version conflict ([#14329](https://github.com/astral-sh/uv/pull/14329))
## 0.7.16
### Python
- Add Python 3.14.0b3
See the
[`python-build-standalone` release notes](https://github.com/astral-sh/python-build-standalone/releases/tag/20250626)
for more details.
### Enhancements
- Include path or URL when failing to convert in lockfile ([#14292](https://github.com/astral-sh/uv/pull/14292))
- Warn when `~=` is used as a Python version specifier without a patch version ([#14008](https://github.com/astral-sh/uv/pull/14008))
### Preview features
- Ensure preview default Python installs are upgradeable ([#14261](https://github.com/astral-sh/uv/pull/14261))
### Performance
- Share workspace cache between lock and sync operations ([#14321](https://github.com/astral-sh/uv/pull/14321))
### Bug fixes
- Allow local indexes to reference remote files ([#14294](https://github.com/astral-sh/uv/pull/14294))
- Avoid rendering desugared prefix matches in error messages ([#14195](https://github.com/astral-sh/uv/pull/14195))
- Avoid using path URL for workspace Git dependencies in `requirements.txt` ([#14288](https://github.com/astral-sh/uv/pull/14288))
- Normalize index URLs to remove trailing slash ([#14245](https://github.com/astral-sh/uv/pull/14245))
- Respect URL-encoded credentials in redirect location ([#14315](https://github.com/astral-sh/uv/pull/14315))
- Lock the source tree when running setuptools, to protect concurrent builds ([#14174](https://github.com/astral-sh/uv/pull/14174))
### Documentation
- Note that GCP Artifact Registry download URLs must have `/simple` component ([#14251](https://github.com/astral-sh/uv/pull/14251))
## 0.7.15
### Enhancements
- Consistently use `Ordering::Relaxed` for standalone atomic use cases ([#14190](https://github.com/astral-sh/uv/pull/14190))
- Warn on ambiguous relative paths for `--index` ([#14152](https://github.com/astral-sh/uv/pull/14152))
- Skip GitHub fast path when rate-limited ([#13033](https://github.com/astral-sh/uv/pull/13033))
- Preserve newlines in `schema.json` descriptions ([#13693](https://github.com/astral-sh/uv/pull/13693))
### Bug fixes
- Add check for using minor version link when creating a venv on Windows ([#14252](https://github.com/astral-sh/uv/pull/14252))
- Strip query parameters when parsing source URL ([#14224](https://github.com/astral-sh/uv/pull/14224))
### Documentation
- Add a link to PyPI FAQ to clarify what per-project token is ([#14242](https://github.com/astral-sh/uv/pull/14242))
### Preview features
- Allow symlinks in the build backend ([#14212](https://github.com/astral-sh/uv/pull/14212))
## 0.7.14 ## 0.7.14
### Enhancements ### Enhancements

View file

@ -165,13 +165,6 @@ After making changes to the documentation, format the markdown files with:
npx prettier --prose-wrap always --write "**/*.md" npx prettier --prose-wrap always --write "**/*.md"
``` ```
Note that the command above requires Node.js and npm to be installed on your system. As an
alternative, you can run this command using Docker:
```console
$ docker run --rm -v .:/src/ -w /src/ node:alpine npx prettier --prose-wrap always --write "**/*.md"
```
## Releases ## Releases
Releases can only be performed by Astral team members. Releases can only be performed by Astral team members.

209
Cargo.lock generated
View file

@ -94,15 +94,6 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "arbitrary" name = "arbitrary"
version = "1.4.1" version = "1.4.1"
@ -189,9 +180,9 @@ dependencies = [
[[package]] [[package]]
name = "async-channel" name = "async-channel"
version = "2.5.0" version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a"
dependencies = [ dependencies = [
"concurrent-queue", "concurrent-queue",
"event-listener-strategy", "event-listener-strategy",
@ -251,7 +242,7 @@ dependencies = [
[[package]] [[package]]
name = "async_zip" name = "async_zip"
version = "0.0.17" version = "0.0.17"
source = "git+https://github.com/astral-sh/rs-async-zip?rev=c909fda63fcafe4af496a07bfda28a5aae97e58d#c909fda63fcafe4af496a07bfda28a5aae97e58d" source = "git+https://github.com/charliermarsh/rs-async-zip?rev=c909fda63fcafe4af496a07bfda28a5aae97e58d#c909fda63fcafe4af496a07bfda28a5aae97e58d"
dependencies = [ dependencies = [
"async-compression", "async-compression",
"crc32fast", "crc32fast",
@ -373,15 +364,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bisection" name = "bisection"
version = "0.1.0" version = "0.1.0"
@ -530,9 +512,9 @@ dependencies = [
[[package]] [[package]]
name = "cargo-util" name = "cargo-util"
version = "0.2.21" version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c95ec8b2485b20aed818bd7460f8eecc6c87c35c84191b353a3aba9aa1736c36" checksum = "d767bc85f367f6483a6072430b56f5c0d6ee7636751a21a800526d0711753d76"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"core-foundation", "core-foundation",
@ -690,27 +672,22 @@ checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]] [[package]]
name = "codspeed" name = "codspeed"
version = "3.0.2" version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "922018102595f6668cdd09c03f4bff2d951ce2318c6dca4fe11bdcb24b65b2bf" checksum = "93f4cce9c27c49c4f101fffeebb1826f41a9df2e7498b7cd4d95c0658b796c6c"
dependencies = [ dependencies = [
"anyhow",
"bincode",
"colored", "colored",
"glob",
"libc", "libc",
"nix 0.29.0",
"serde", "serde",
"serde_json", "serde_json",
"statrs",
"uuid", "uuid",
] ]
[[package]] [[package]]
name = "codspeed-criterion-compat" name = "codspeed-criterion-compat"
version = "3.0.2" version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d8ad82d2383cb74995f58993cbdd2914aed57b2f91f46580310dd81dc3d05a" checksum = "c3c23d880a28a2aab52d38ca8481dd7a3187157d0a952196b6db1db3c8499725"
dependencies = [ dependencies = [
"codspeed", "codspeed",
"codspeed-criterion-compat-walltime", "codspeed-criterion-compat-walltime",
@ -719,9 +696,9 @@ dependencies = [
[[package]] [[package]]
name = "codspeed-criterion-compat-walltime" name = "codspeed-criterion-compat-walltime"
version = "3.0.2" version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61badaa6c452d192a29f8387147888f0ab358553597c3fe9bf8a162ef7c2fa64" checksum = "7b0a2f7365e347f4f22a67e9ea689bf7bc89900a354e22e26cf8a531a42c8fbb"
dependencies = [ dependencies = [
"anes", "anes",
"cast", "cast",
@ -1165,9 +1142,9 @@ dependencies = [
[[package]] [[package]]
name = "event-listener-strategy" name = "event-listener-strategy"
version = "0.5.4" version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2"
dependencies = [ dependencies = [
"event-listener", "event-listener",
"pin-project-lite", "pin-project-lite",
@ -1698,7 +1675,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots 0.26.8", "webpki-roots",
] ]
[[package]] [[package]]
@ -1707,7 +1684,6 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb"
dependencies = [ dependencies = [
"base64 0.22.1",
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -1715,9 +1691,7 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"hyper", "hyper",
"ipnet",
"libc", "libc",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio", "tokio",
@ -1899,9 +1873,9 @@ checksum = "b72ad49b554c1728b1e83254a1b1565aea4161e28dabbfa171fc15fe62299caf"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.10.0" version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.15.4", "hashbrown 0.15.4",
@ -1948,16 +1922,6 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
dependencies = [
"memchr",
"serde",
]
[[package]] [[package]]
name = "is-terminal" name = "is-terminal"
version = "0.4.15" version = "0.4.15"
@ -2510,9 +2474,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]] [[package]]
name = "owo-colors" name = "owo-colors"
version = "4.2.2" version = "4.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec"
[[package]] [[package]]
name = "parking" name = "parking"
@ -3075,9 +3039,9 @@ dependencies = [
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.22" version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
dependencies = [ dependencies = [
"async-compression", "async-compression",
"base64 0.22.1", "base64 0.22.1",
@ -3092,14 +3056,18 @@ dependencies = [
"hyper", "hyper",
"hyper-rustls", "hyper-rustls",
"hyper-util", "hyper-util",
"ipnet",
"js-sys", "js-sys",
"log", "log",
"mime",
"mime_guess", "mime_guess",
"once_cell",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"rustls-pemfile",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
@ -3107,16 +3075,17 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-socks",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams",
"web-sys", "web-sys",
"webpki-roots 1.0.1", "webpki-roots",
"windows-registry 0.4.0",
] ]
[[package]] [[package]]
@ -3359,6 +3328,15 @@ dependencies = [
"security-framework", "security-framework",
] ]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.11.0" version = "1.11.0"
@ -3427,12 +3405,11 @@ dependencies = [
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "1.0.4" version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [ dependencies = [
"dyn-clone", "dyn-clone",
"ref-cast",
"schemars_derive", "schemars_derive",
"serde", "serde",
"serde_json", "serde_json",
@ -3441,9 +3418,9 @@ dependencies = [
[[package]] [[package]]
name = "schemars_derive" name = "schemars_derive"
version = "1.0.4" version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -3742,16 +3719,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "statrs"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e"
dependencies = [
"approx",
"num-traits",
]
[[package]] [[package]]
name = "strict-num" name = "strict-num"
version = "0.1.1" version = "0.1.1"
@ -3967,9 +3934,9 @@ dependencies = [
[[package]] [[package]]
name = "test-log" name = "test-log"
version = "0.2.18" version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" checksum = "e7f46083d221181166e5b6f6b1e5f1d499f3a76888826e6cb1d057554157cd0f"
dependencies = [ dependencies = [
"test-log-macros", "test-log-macros",
"tracing-subscriber", "tracing-subscriber",
@ -3977,9 +3944,9 @@ dependencies = [
[[package]] [[package]]
name = "test-log-macros" name = "test-log-macros"
version = "0.2.18" version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" checksum = "888d0c3c6db53c0fdab160d2ed5e12ba745383d3e85813f2ea0f2b1475ab553f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -4171,6 +4138,18 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "tokio-socks"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f"
dependencies = [
"either",
"futures-util",
"thiserror 1.0.69",
"tokio",
]
[[package]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.17" version = "0.1.17"
@ -4253,24 +4232,6 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "tower-http"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.1",
"bytes",
"futures-util",
"http",
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "tower-layer" name = "tower-layer"
version = "0.3.3" version = "0.3.3"
@ -4608,7 +4569,7 @@ dependencies = [
[[package]] [[package]]
name = "uv" name = "uv"
version = "0.7.20" version = "0.7.14"
dependencies = [ dependencies = [
"anstream", "anstream",
"anyhow", "anyhow",
@ -4622,6 +4583,7 @@ dependencies = [
"ctrlc", "ctrlc",
"dotenvy", "dotenvy",
"dunce", "dunce",
"etcetera",
"filetime", "filetime",
"flate2", "flate2",
"fs-err 3.1.1", "fs-err 3.1.1",
@ -4757,6 +4719,7 @@ dependencies = [
"uv-configuration", "uv-configuration",
"uv-dispatch", "uv-dispatch",
"uv-distribution", "uv-distribution",
"uv-distribution-filename",
"uv-distribution-types", "uv-distribution-types",
"uv-extract", "uv-extract",
"uv-install-wheel", "uv-install-wheel",
@ -4772,7 +4735,7 @@ dependencies = [
[[package]] [[package]]
name = "uv-build" name = "uv-build"
version = "0.7.20" version = "0.7.14"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"uv-build-backend", "uv-build-backend",
@ -4790,7 +4753,6 @@ dependencies = [
"indoc", "indoc",
"insta", "insta",
"itertools 0.14.0", "itertools 0.14.0",
"rustc-hash",
"schemars", "schemars",
"serde", "serde",
"sha2", "sha2",
@ -4836,7 +4798,6 @@ dependencies = [
"tokio", "tokio",
"toml_edit", "toml_edit",
"tracing", "tracing",
"uv-cache-key",
"uv-configuration", "uv-configuration",
"uv-distribution", "uv-distribution",
"uv-distribution-types", "uv-distribution-types",
@ -5174,6 +5135,7 @@ dependencies = [
"serde", "serde",
"smallvec", "smallvec",
"thiserror 2.0.12", "thiserror 2.0.12",
"url",
"uv-cache-key", "uv-cache-key",
"uv-normalize", "uv-normalize",
"uv-pep440", "uv-pep440",
@ -5216,7 +5178,6 @@ dependencies = [
"uv-pypi-types", "uv-pypi-types",
"uv-redacted", "uv-redacted",
"uv-small-str", "uv-small-str",
"uv-warnings",
"version-ranges", "version-ranges",
] ]
@ -5641,7 +5602,7 @@ dependencies = [
"uv-trampoline-builder", "uv-trampoline-builder",
"uv-warnings", "uv-warnings",
"which", "which",
"windows-registry", "windows-registry 0.5.2",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@ -5845,7 +5806,7 @@ dependencies = [
"tracing", "tracing",
"uv-fs", "uv-fs",
"uv-static", "uv-static",
"windows-registry", "windows-registry 0.5.2",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
@ -5943,7 +5904,6 @@ name = "uv-types"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dashmap",
"rustc-hash", "rustc-hash",
"thiserror 2.0.12", "thiserror 2.0.12",
"uv-cache", "uv-cache",
@ -5963,7 +5923,7 @@ dependencies = [
[[package]] [[package]]
name = "uv-version" name = "uv-version"
version = "0.7.20" version = "0.7.14"
[[package]] [[package]]
name = "uv-virtualenv" name = "uv-virtualenv"
@ -6227,15 +6187,6 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.8" version = "0.1.8"
@ -6379,7 +6330,7 @@ dependencies = [
"windows-interface 0.59.1", "windows-interface 0.59.1",
"windows-link", "windows-link",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-strings 0.4.2", "windows-strings 0.4.1",
] ]
[[package]] [[package]]
@ -6449,9 +6400,9 @@ dependencies = [
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.1.3" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
[[package]] [[package]]
name = "windows-numerics" name = "windows-numerics"
@ -6465,13 +6416,24 @@ dependencies = [
[[package]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.5.3" version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3"
dependencies = [
"windows-result 0.3.4",
"windows-strings 0.3.1",
"windows-targets 0.53.0",
]
[[package]]
name = "windows-registry"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820"
dependencies = [ dependencies = [
"windows-link", "windows-link",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-strings 0.4.2", "windows-strings 0.4.1",
] ]
[[package]] [[package]]
@ -6503,9 +6465,9 @@ dependencies = [
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.4.2" version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" checksum = "2a7ab927b2637c19b3dbe0965e75d8f2d30bdd697a1516191cad2ec4df8fb28a"
dependencies = [ dependencies = [
"windows-link", "windows-link",
] ]
@ -6739,9 +6701,8 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]] [[package]]
name = "wiremock" name = "wiremock"
version = "0.6.4" version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/astral-sh/wiremock-rs?rev=b79b69f62521df9f83a54e866432397562eae789#b79b69f62521df9f83a54e866432397562eae789"
checksum = "a2b8b99d4cdbf36b239a9532e31fe4fb8acc38d1897c1761e161550a7dc78e6a"
dependencies = [ dependencies = [
"assert-json-diff", "assert-json-diff",
"async-trait", "async-trait",

View file

@ -12,7 +12,7 @@ resolver = "2"
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.85"
homepage = "https://pypi.org/project/uv/" homepage = "https://pypi.org/project/uv/"
documentation = "https://pypi.org/project/uv/" documentation = "https://pypi.org/project/uv/"
repository = "https://github.com/astral-sh/uv" repository = "https://github.com/astral-sh/uv"
@ -80,7 +80,7 @@ async-channel = { version = "2.3.1" }
async-compression = { version = "0.4.12", features = ["bzip2", "gzip", "xz", "zstd"] } async-compression = { version = "0.4.12", features = ["bzip2", "gzip", "xz", "zstd"] }
async-trait = { version = "0.1.82" } async-trait = { version = "0.1.82" }
async_http_range_reader = { version = "0.9.1" } async_http_range_reader = { version = "0.9.1" }
async_zip = { git = "https://github.com/astral-sh/rs-async-zip", rev = "c909fda63fcafe4af496a07bfda28a5aae97e58d", features = ["bzip2", "deflate", "lzma", "tokio", "xz", "zstd"] } async_zip = { git = "https://github.com/charliermarsh/rs-async-zip", rev = "c909fda63fcafe4af496a07bfda28a5aae97e58d", features = ["bzip2", "deflate", "lzma", "tokio", "xz", "zstd"] }
axoupdater = { version = "0.9.0", default-features = false } axoupdater = { version = "0.9.0", default-features = false }
backon = { version = "1.3.0" } backon = { version = "1.3.0" }
base64 = { version = "0.22.1" } base64 = { version = "0.22.1" }
@ -142,7 +142,7 @@ ref-cast = { version = "1.0.24" }
reflink-copy = { version = "0.1.19" } reflink-copy = { version = "0.1.19" }
regex = { version = "1.10.6" } regex = { version = "1.10.6" }
regex-automata = { version = "0.4.8", default-features = false, features = ["dfa-build", "dfa-search", "perf", "std", "syntax"] } regex-automata = { version = "0.4.8", default-features = false, features = ["dfa-build", "dfa-search", "perf", "std", "syntax"] }
reqwest = { version = "0.12.22", default-features = false, features = ["json", "gzip", "deflate", "zstd", "stream", "rustls-tls", "rustls-tls-native-roots", "socks", "multipart", "http2", "blocking"] } reqwest = { version = "=0.12.15", default-features = false, features = ["json", "gzip", "deflate", "zstd", "stream", "rustls-tls", "rustls-tls-native-roots", "socks", "multipart", "http2", "blocking"] }
reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "ad8b9d332d1773fde8b4cd008486de5973e0a3f8", features = ["multipart"] } reqwest-middleware = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "ad8b9d332d1773fde8b4cd008486de5973e0a3f8", features = ["multipart"] }
reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "ad8b9d332d1773fde8b4cd008486de5973e0a3f8" } reqwest-retry = { git = "https://github.com/astral-sh/reqwest-middleware", rev = "ad8b9d332d1773fde8b4cd008486de5973e0a3f8" }
rkyv = { version = "0.8.8", features = ["bytecheck"] } rkyv = { version = "0.8.8", features = ["bytecheck"] }
@ -151,7 +151,7 @@ rust-netrc = { version = "0.1.2" }
rustc-hash = { version = "2.0.0" } rustc-hash = { version = "2.0.0" }
rustix = { version = "1.0.0", default-features = false, features = ["fs", "std"] } rustix = { version = "1.0.0", default-features = false, features = ["fs", "std"] }
same-file = { version = "1.0.6" } same-file = { version = "1.0.6" }
schemars = { version = "1.0.0", features = ["url2"] } schemars = { version = "0.8.21", features = ["url"] }
seahash = { version = "4.1.0" } seahash = { version = "4.1.0" }
self-replace = { version = "1.5.0" } self-replace = { version = "1.5.0" }
serde = { version = "1.0.210", features = ["derive", "rc"] } serde = { version = "1.0.210", features = ["derive", "rc"] }
@ -189,7 +189,7 @@ windows-core = { version = "0.59.0" }
windows-registry = { version = "0.5.0" } windows-registry = { version = "0.5.0" }
windows-result = { version = "0.3.0" } windows-result = { version = "0.3.0" }
windows-sys = { version = "0.59.0", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Ioctl", "Win32_System_IO", "Win32_System_Registry"] } windows-sys = { version = "0.59.0", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Ioctl", "Win32_System_IO", "Win32_System_Registry"] }
wiremock = { version = "0.6.4" } wiremock = { git = "https://github.com/astral-sh/wiremock-rs", rev = "b79b69f62521df9f83a54e866432397562eae789" }
xz2 = { version = "0.1.7" } xz2 = { version = "0.1.7" }
zip = { version = "2.2.3", default-features = false, features = ["deflate", "zstd", "bzip2", "lzma", "xz"] } zip = { version = "2.2.3", default-features = false, features = ["deflate", "zstd", "bzip2", "lzma", "xz"] }

View file

@ -37,7 +37,7 @@ disallowed-methods = [
"std::fs::soft_link", "std::fs::soft_link",
"std::fs::symlink_metadata", "std::fs::symlink_metadata",
"std::fs::write", "std::fs::write",
{ path = "std::os::unix::fs::symlink", allow-invalid = true }, "std::os::unix::fs::symlink",
{ path = "std::os::windows::fs::symlink_dir", allow-invalid = true }, "std::os::windows::fs::symlink_dir",
{ path = "std::os::windows::fs::symlink_file", allow-invalid = true }, "std::os::windows::fs::symlink_file",
] ]

View file

@ -86,7 +86,7 @@ impl Indexes {
Self(FxHashSet::default()) Self(FxHashSet::default())
} }
/// Create a new [`Indexes`] instance from an iterator of [`Index`]s. /// Create a new [`AuthIndexUrls`] from an iterator of [`AuthIndexUrl`]s.
pub fn from_indexes(urls: impl IntoIterator<Item = Index>) -> Self { pub fn from_indexes(urls: impl IntoIterator<Item = Index>) -> Self {
let mut index_urls = Self::new(); let mut index_urls = Self::new();
for url in urls { for url in urls {

View file

@ -18,6 +18,11 @@ workspace = true
doctest = false doctest = false
bench = false bench = false
[[bench]]
name = "distribution-filename"
path = "benches/distribution_filename.rs"
harness = false
[[bench]] [[bench]]
name = "uv" name = "uv"
path = "benches/uv.rs" path = "benches/uv.rs"
@ -29,6 +34,7 @@ uv-client = { workspace = true }
uv-configuration = { workspace = true } uv-configuration = { workspace = true }
uv-dispatch = { workspace = true } uv-dispatch = { workspace = true }
uv-distribution = { workspace = true } uv-distribution = { workspace = true }
uv-distribution-filename = { workspace = true }
uv-distribution-types = { workspace = true } uv-distribution-types = { workspace = true }
uv-extract = { workspace = true, optional = true } uv-extract = { workspace = true, optional = true }
uv-install-wheel = { workspace = true } uv-install-wheel = { workspace = true }
@ -42,10 +48,8 @@ uv-types = { workspace = true }
uv-workspace = { workspace = true } uv-workspace = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
codspeed-criterion-compat = { version = "3.0.2", default-features = false, optional = true } codspeed-criterion-compat = { version = "2.7.2", default-features = false, optional = true }
criterion = { version = "0.6.0", default-features = false, features = [ criterion = { version = "0.6.0", default-features = false, features = ["async_tokio"] }
"async_tokio",
] }
jiff = { workspace = true } jiff = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }

View file

@ -0,0 +1,168 @@
use std::str::FromStr;
use uv_bench::criterion::{
BenchmarkId, Criterion, Throughput, criterion_group, criterion_main, measurement::WallTime,
};
use uv_distribution_filename::WheelFilename;
use uv_platform_tags::{AbiTag, LanguageTag, PlatformTag, Tags};
/// A set of platform tags extracted from burntsushi's Archlinux workstation.
/// We could just re-create these via `Tags::from_env`, but those might differ
/// depending on the platform. This way, we always use the same data. It also
/// lets us assert tag compatibility regardless of where the benchmarks run.
const PLATFORM_TAGS: &[(&str, &str, &str)] = include!("../inputs/platform_tags.rs");
/// A set of wheel names used in the benchmarks below. We pick short and long
/// names, as well as compatible and not-compatibles (with `PLATFORM_TAGS`)
/// names.
///
/// The tuple is (name, filename, compatible) where `name` is a descriptive
/// name for humans used in the benchmark definition. And `filename` is the
/// actual wheel filename we want to benchmark operation on. And `compatible`
/// indicates whether the tags in the wheel filename are expected to be
/// compatible with the tags in `PLATFORM_TAGS`.
const WHEEL_NAMES: &[(&str, &str, bool)] = &[
// This tests a case with a very short name that *is* compatible with
// PLATFORM_TAGS. It only uses one tag for each component (one Python
// version, one ABI and one platform).
(
"flyte-short-compatible",
"ipython-2.1.0-py3-none-any.whl",
true,
),
// This tests a case with a long name that is *not* compatible. That
// is, all platform tags need to be checked against the tags in the
// wheel filename. This is essentially the worst possible practical
// case.
(
"flyte-long-incompatible",
"protobuf-3.5.2.post1-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl",
false,
),
// This tests a case with a long name that *is* compatible. We
// expect this to be (on average) quicker because the compatibility
// check stops as soon as a positive match is found. (Where as the
// incompatible case needs to check all tags.)
(
"flyte-long-compatible",
"coverage-6.6.0b1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
true,
),
];
/// A list of names that are candidates for wheel filenames but will ultimately
/// fail to parse.
const INVALID_WHEEL_NAMES: &[(&str, &str)] = &[
("flyte-short-extension", "mock-5.1.0.tar.gz"),
(
"flyte-long-extension",
"Pillow-5.4.0.dev0-py3.7-macosx-10.13-x86_64.egg",
),
];
/// Benchmarks the construction of platform tags.
///
/// This only happens ~once per program startup. Originally, construction was
/// trivial. But to speed up `WheelFilename::is_compatible`, we added some
/// extra processing. We thus expect construction to become slower, but we
/// write a benchmark to ensure it is still "reasonable."
fn benchmark_build_platform_tags(c: &mut Criterion<WallTime>) {
let tags: Vec<(LanguageTag, AbiTag, PlatformTag)> = PLATFORM_TAGS
.iter()
.map(|&(py, abi, plat)| {
(
LanguageTag::from_str(py).unwrap(),
AbiTag::from_str(abi).unwrap(),
PlatformTag::from_str(plat).unwrap(),
)
})
.collect();
let mut group = c.benchmark_group("build_platform_tags");
group.bench_function(BenchmarkId::from_parameter("burntsushi-archlinux"), |b| {
b.iter(|| std::hint::black_box(Tags::new(tags.clone())));
});
group.finish();
}
/// Benchmarks `WheelFilename::from_str`. This has been observed to take some
/// non-trivial time in profiling (although, at time of writing, not as much
/// as tag compatibility). In the process of optimizing tag compatibility,
/// we tweaked wheel filename parsing. This benchmark was therefore added to
/// ensure we didn't regress here.
fn benchmark_wheelname_parsing(c: &mut Criterion<WallTime>) {
let mut group = c.benchmark_group("wheelname_parsing");
for (name, filename, _) in WHEEL_NAMES.iter().copied() {
let len = u64::try_from(filename.len()).expect("length fits in u64");
group.throughput(Throughput::Bytes(len));
group.bench_function(BenchmarkId::from_parameter(name), |b| {
b.iter(|| {
filename
.parse::<WheelFilename>()
.expect("valid wheel filename");
});
});
}
group.finish();
}
/// Benchmarks `WheelFilename::from_str` when it fails. This routine is called
/// on every filename in a package's metadata. A non-trivial portion of which
/// are not wheel filenames. Ensuring that the error path is fast is thus
/// probably a good idea.
fn benchmark_wheelname_parsing_failure(c: &mut Criterion<WallTime>) {
let mut group = c.benchmark_group("wheelname_parsing_failure");
for (name, filename) in INVALID_WHEEL_NAMES.iter().copied() {
let len = u64::try_from(filename.len()).expect("length fits in u64");
group.throughput(Throughput::Bytes(len));
group.bench_function(BenchmarkId::from_parameter(name), |b| {
b.iter(|| {
filename
.parse::<WheelFilename>()
.expect_err("invalid wheel filename");
});
});
}
group.finish();
}
/// Benchmarks the `WheelFilename::is_compatible` routine. This was revealed
/// to be the #1 bottleneck in the resolver. The main issue was that the
/// set of platform tags (generated once) is quite large, and the original
/// implementation did an exhaustive search over each of them for each tag in
/// the wheel filename.
fn benchmark_wheelname_tag_compatibility(c: &mut Criterion<WallTime>) {
let tags: Vec<(LanguageTag, AbiTag, PlatformTag)> = PLATFORM_TAGS
.iter()
.map(|&(py, abi, plat)| {
(
LanguageTag::from_str(py).unwrap(),
AbiTag::from_str(abi).unwrap(),
PlatformTag::from_str(plat).unwrap(),
)
})
.collect();
let tags = Tags::new(tags);
let mut group = c.benchmark_group("wheelname_tag_compatibility");
for (name, filename, expected) in WHEEL_NAMES.iter().copied() {
let wheelname: WheelFilename = filename.parse().expect("valid wheel filename");
let len = u64::try_from(filename.len()).expect("length fits in u64");
group.throughput(Throughput::Bytes(len));
group.bench_function(BenchmarkId::from_parameter(name), |b| {
b.iter(|| {
assert_eq!(expected, wheelname.is_compatible(&tags));
});
});
}
group.finish();
}
criterion_group!(
uv_distribution_filename,
benchmark_build_platform_tags,
benchmark_wheelname_parsing,
benchmark_wheelname_parsing_failure,
benchmark_wheelname_tag_compatibility,
);
criterion_main!(uv_distribution_filename);

View file

@ -31,7 +31,6 @@ flate2 = { workspace = true, default-features = false }
fs-err = { workspace = true } fs-err = { workspace = true }
globset = { workspace = true } globset = { workspace = true }
itertools = { workspace = true } itertools = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true } schemars = { workspace = true, optional = true }
serde = { workspace = true } serde = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }

View file

@ -9,12 +9,12 @@ pub use settings::{BuildBackendSettings, WheelDataIncludes};
pub use source_dist::{build_source_dist, list_source_dist}; pub use source_dist::{build_source_dist, list_source_dist};
pub use wheel::{build_editable, build_wheel, list_wheel, metadata}; pub use wheel::{build_editable, build_wheel, list_wheel, metadata};
use std::fs::FileType;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use thiserror::Error; use thiserror::Error;
use tracing::debug; use tracing::debug;
use walkdir::DirEntry;
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_globfilter::PortableGlobError; use uv_globfilter::PortableGlobError;
@ -22,7 +22,6 @@ use uv_normalize::PackageName;
use uv_pypi_types::{Identifier, IdentifierParseError}; use uv_pypi_types::{Identifier, IdentifierParseError};
use crate::metadata::ValidationError; use crate::metadata::ValidationError;
use crate::settings::ModuleName;
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {
@ -55,6 +54,8 @@ pub enum Error {
#[source] #[source]
err: walkdir::Error, err: walkdir::Error,
}, },
#[error("Unsupported file type {:?}: `{}`", _1, _0.user_display())]
UnsupportedFileType(PathBuf, FileType),
#[error("Failed to write wheel zip archive")] #[error("Failed to write wheel zip archive")]
Zip(#[from] zip::result::ZipError), Zip(#[from] zip::result::ZipError),
#[error("Failed to write RECORD file")] #[error("Failed to write RECORD file")]
@ -85,16 +86,6 @@ trait DirectoryWriter {
/// Files added through the method are considered generated when listing included files. /// Files added through the method are considered generated when listing included files.
fn write_bytes(&mut self, path: &str, bytes: &[u8]) -> Result<(), Error>; fn write_bytes(&mut self, path: &str, bytes: &[u8]) -> Result<(), Error>;
/// Add the file or directory to the path.
fn write_dir_entry(&mut self, entry: &DirEntry, target_path: &str) -> Result<(), Error> {
if entry.file_type().is_dir() {
self.write_directory(target_path)?;
} else {
self.write_file(target_path, entry.path())?;
}
Ok(())
}
/// Add a local file. /// Add a local file.
fn write_file(&mut self, path: &str, file: &Path) -> Result<(), Error>; fn write_file(&mut self, path: &str, file: &Path) -> Result<(), Error>;
@ -185,7 +176,7 @@ fn check_metadata_directory(
Ok(()) Ok(())
} }
/// Returns the source root and the module path(s) with the `__init__.py[i]` below to it while /// Returns the source root and the module path with the `__init__.py[i]` below to it while
/// checking the project layout and names. /// checking the project layout and names.
/// ///
/// Some target platforms have case-sensitive filesystems, while others have case-insensitive /// Some target platforms have case-sensitive filesystems, while others have case-insensitive
@ -199,15 +190,13 @@ fn check_metadata_directory(
/// dist-info-normalization, the rules are lowercasing, replacing `.` with `_` and /// dist-info-normalization, the rules are lowercasing, replacing `.` with `_` and
/// replace `-` with `_`. Since `.` and `-` are not allowed in identifiers, we can use a string /// replace `-` with `_`. Since `.` and `-` are not allowed in identifiers, we can use a string
/// comparison with the module name. /// comparison with the module name.
///
/// While we recommend one module per package, it is possible to declare a list of modules.
fn find_roots( fn find_roots(
source_tree: &Path, source_tree: &Path,
pyproject_toml: &PyProjectToml, pyproject_toml: &PyProjectToml,
relative_module_root: &Path, relative_module_root: &Path,
module_name: Option<&ModuleName>, module_name: Option<&str>,
namespace: bool, namespace: bool,
) -> Result<(PathBuf, Vec<PathBuf>), Error> { ) -> Result<(PathBuf, PathBuf), Error> {
let relative_module_root = uv_fs::normalize_path(relative_module_root); let relative_module_root = uv_fs::normalize_path(relative_module_root);
let src_root = source_tree.join(&relative_module_root); let src_root = source_tree.join(&relative_module_root);
if !src_root.starts_with(source_tree) { if !src_root.starts_with(source_tree) {
@ -218,45 +207,22 @@ fn find_roots(
if namespace { if namespace {
// `namespace = true` disables module structure checks. // `namespace = true` disables module structure checks.
let modules_relative = if let Some(module_name) = module_name { let module_relative = if let Some(module_name) = module_name {
match module_name { module_name.split('.').collect::<PathBuf>()
ModuleName::Name(name) => {
vec![name.split('.').collect::<PathBuf>()]
}
ModuleName::Names(names) => names
.iter()
.map(|name| name.split('.').collect::<PathBuf>())
.collect(),
}
} else { } else {
vec![PathBuf::from( PathBuf::from(pyproject_toml.name().as_dist_info_name().to_string())
pyproject_toml.name().as_dist_info_name().to_string(),
)]
}; };
for module_relative in &modules_relative { debug!("Namespace module path: {}", module_relative.user_display());
debug!("Namespace module path: {}", module_relative.user_display()); return Ok((src_root, module_relative));
}
return Ok((src_root, modules_relative));
} }
let modules_relative = if let Some(module_name) = module_name { let module_relative = if let Some(module_name) = module_name {
match module_name { module_path_from_module_name(&src_root, module_name)?
ModuleName::Name(name) => vec![module_path_from_module_name(&src_root, name)?],
ModuleName::Names(names) => names
.iter()
.map(|name| module_path_from_module_name(&src_root, name))
.collect::<Result<_, _>>()?,
}
} else { } else {
vec![find_module_path_from_package_name( find_module_path_from_package_name(&src_root, pyproject_toml.name())?
&src_root,
pyproject_toml.name(),
)?]
}; };
for module_relative in &modules_relative { debug!("Module path: {}", module_relative.user_display());
debug!("Module path: {}", module_relative.user_display()); Ok((src_root, module_relative))
}
Ok((src_root, modules_relative))
} }
/// Infer stubs packages from package name alone. /// Infer stubs packages from package name alone.
@ -436,15 +402,6 @@ mod tests {
}) })
} }
fn build_err(source_root: &Path) -> String {
let dist = TempDir::new().unwrap();
let build_err = build(source_root, dist.path()).unwrap_err();
let err_message: String = format_err(&build_err)
.replace(&source_root.user_display().to_string(), "[TEMP_PATH]")
.replace('\\', "/");
err_message
}
fn sdist_contents(source_dist_path: &Path) -> Vec<String> { fn sdist_contents(source_dist_path: &Path) -> Vec<String> {
let sdist_reader = BufReader::new(File::open(source_dist_path).unwrap()); let sdist_reader = BufReader::new(File::open(source_dist_path).unwrap());
let mut source_dist = tar::Archive::new(GzDecoder::new(sdist_reader)); let mut source_dist = tar::Archive::new(GzDecoder::new(sdist_reader));
@ -1033,8 +990,13 @@ mod tests {
fs_err::create_dir_all(src.path().join("src").join("simple_namespace").join("part")) fs_err::create_dir_all(src.path().join("src").join("simple_namespace").join("part"))
.unwrap(); .unwrap();
let dist = TempDir::new().unwrap();
let build_err = build(src.path(), dist.path()).unwrap_err();
let err_message = format_err(&build_err)
.replace(&src.path().user_display().to_string(), "[TEMP_PATH]")
.replace('\\', "/");
assert_snapshot!( assert_snapshot!(
build_err(src.path()), err_message,
@"Expected a Python module at: `[TEMP_PATH]/src/simple_namespace/part/__init__.py`" @"Expected a Python module at: `[TEMP_PATH]/src/simple_namespace/part/__init__.py`"
); );
@ -1055,13 +1017,16 @@ mod tests {
.join("simple_namespace") .join("simple_namespace")
.join("__init__.py"); .join("__init__.py");
File::create(&bogus_init_py).unwrap(); File::create(&bogus_init_py).unwrap();
let build_err = build(src.path(), dist.path()).unwrap_err();
let err_message = format_err(&build_err)
.replace(&src.path().user_display().to_string(), "[TEMP_PATH]")
.replace('\\', "/");
assert_snapshot!( assert_snapshot!(
build_err(src.path()), err_message,
@"For namespace packages, `__init__.py[i]` is not allowed in parent directory: `[TEMP_PATH]/src/simple_namespace`" @"For namespace packages, `__init__.py[i]` is not allowed in parent directory: `[TEMP_PATH]/src/simple_namespace`"
); );
fs_err::remove_file(bogus_init_py).unwrap(); fs_err::remove_file(bogus_init_py).unwrap();
let dist = TempDir::new().unwrap();
let build1 = build(src.path(), dist.path()).unwrap(); let build1 = build(src.path(), dist.path()).unwrap();
assert_snapshot!(build1.source_dist_contents.join("\n"), @r" assert_snapshot!(build1.source_dist_contents.join("\n"), @r"
simple_namespace_part-1.0.0/ simple_namespace_part-1.0.0/
@ -1236,117 +1201,4 @@ mod tests {
cloud_db_schema_stubs-1.0.0.dist-info/WHEEL cloud_db_schema_stubs-1.0.0.dist-info/WHEEL
"); ");
} }
/// A package with multiple modules, one a regular module and two namespace modules.
#[test]
fn multiple_module_names() {
let src = TempDir::new().unwrap();
let pyproject_toml = indoc! {r#"
[project]
name = "simple-namespace-part"
version = "1.0.0"
[tool.uv.build-backend]
module-name = ["foo", "simple_namespace.part_a", "simple_namespace.part_b"]
[build-system]
requires = ["uv_build>=0.5.15,<0.6"]
build-backend = "uv_build"
"#
};
fs_err::write(src.path().join("pyproject.toml"), pyproject_toml).unwrap();
fs_err::create_dir_all(src.path().join("src").join("foo")).unwrap();
fs_err::create_dir_all(
src.path()
.join("src")
.join("simple_namespace")
.join("part_a"),
)
.unwrap();
fs_err::create_dir_all(
src.path()
.join("src")
.join("simple_namespace")
.join("part_b"),
)
.unwrap();
// Most of these checks exist in other tests too, but we want to ensure that they apply
// with multiple modules too.
// The first module is missing an `__init__.py`.
assert_snapshot!(
build_err(src.path()),
@"Expected a Python module at: `[TEMP_PATH]/src/foo/__init__.py`"
);
// Create the first correct `__init__.py` file
File::create(src.path().join("src").join("foo").join("__init__.py")).unwrap();
// The second module, a namespace, is missing an `__init__.py`.
assert_snapshot!(
build_err(src.path()),
@"Expected a Python module at: `[TEMP_PATH]/src/simple_namespace/part_a/__init__.py`"
);
// Create the other two correct `__init__.py` files
File::create(
src.path()
.join("src")
.join("simple_namespace")
.join("part_a")
.join("__init__.py"),
)
.unwrap();
File::create(
src.path()
.join("src")
.join("simple_namespace")
.join("part_b")
.join("__init__.py"),
)
.unwrap();
// For the second module, a namespace, there must not be an `__init__.py` here.
let bogus_init_py = src
.path()
.join("src")
.join("simple_namespace")
.join("__init__.py");
File::create(&bogus_init_py).unwrap();
assert_snapshot!(
build_err(src.path()),
@"For namespace packages, `__init__.py[i]` is not allowed in parent directory: `[TEMP_PATH]/src/simple_namespace`"
);
fs_err::remove_file(bogus_init_py).unwrap();
let dist = TempDir::new().unwrap();
let build = build(src.path(), dist.path()).unwrap();
assert_snapshot!(build.source_dist_contents.join("\n"), @r"
simple_namespace_part-1.0.0/
simple_namespace_part-1.0.0/PKG-INFO
simple_namespace_part-1.0.0/pyproject.toml
simple_namespace_part-1.0.0/src
simple_namespace_part-1.0.0/src/foo
simple_namespace_part-1.0.0/src/foo/__init__.py
simple_namespace_part-1.0.0/src/simple_namespace
simple_namespace_part-1.0.0/src/simple_namespace/part_a
simple_namespace_part-1.0.0/src/simple_namespace/part_a/__init__.py
simple_namespace_part-1.0.0/src/simple_namespace/part_b
simple_namespace_part-1.0.0/src/simple_namespace/part_b/__init__.py
");
assert_snapshot!(build.wheel_contents.join("\n"), @r"
foo/
foo/__init__.py
simple_namespace/
simple_namespace/part_a/
simple_namespace/part_a/__init__.py
simple_namespace/part_b/
simple_namespace/part_b/__init__.py
simple_namespace_part-1.0.0.dist-info/
simple_namespace_part-1.0.0.dist-info/METADATA
simple_namespace_part-1.0.0.dist-info/RECORD
simple_namespace_part-1.0.0.dist-info/WHEEL
");
}
} }

View file

@ -4,6 +4,10 @@ use uv_macros::OptionsMetadata;
/// Settings for the uv build backend (`uv_build`). /// Settings for the uv build backend (`uv_build`).
/// ///
/// !!! note
///
/// The uv build backend is currently in preview and may change in any future release.
///
/// Note that those settings only apply when using the `uv_build` backend, other build backends /// Note that those settings only apply when using the `uv_build` backend, other build backends
/// (such as hatchling) have their own configuration. /// (such as hatchling) have their own configuration.
/// ///
@ -34,19 +38,15 @@ pub struct BuildBackendSettings {
/// For namespace packages with a single module, the path can be dotted, e.g., `foo.bar` or /// For namespace packages with a single module, the path can be dotted, e.g., `foo.bar` or
/// `foo-stubs.bar`. /// `foo-stubs.bar`.
/// ///
/// For namespace packages with multiple modules, the path can be a list, e.g.,
/// `["foo", "bar"]`. We recommend using a single module per package, splitting multiple
/// packages into a workspace.
///
/// Note that using this option runs the risk of creating two packages with different names but /// Note that using this option runs the risk of creating two packages with different names but
/// the same module names. Installing such packages together leads to unspecified behavior, /// the same module names. Installing such packages together leads to unspecified behavior,
/// often with corrupted files or directory trees. /// often with corrupted files or directory trees.
#[option( #[option(
default = r#"None"#, default = r#"None"#,
value_type = "str | list[str]", value_type = "str",
example = r#"module-name = "sklearn""# example = r#"module-name = "sklearn""#
)] )]
pub module_name: Option<ModuleName>, pub module_name: Option<String>,
/// Glob expressions which files and directories to additionally include in the source /// Glob expressions which files and directories to additionally include in the source
/// distribution. /// distribution.
@ -185,17 +185,6 @@ impl Default for BuildBackendSettings {
} }
} }
/// Whether to include a single module or multiple modules.
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum ModuleName {
/// A single module name.
Name(String),
/// Multiple module names, which are all included.
Names(Vec<String>),
}
/// Data includes for wheels. /// Data includes for wheels.
/// ///
/// See `BuildBackendSettings::data`. /// See `BuildBackendSettings::data`.

View file

@ -68,24 +68,22 @@ fn source_dist_matcher(
includes.push(globset::escape("pyproject.toml")); includes.push(globset::escape("pyproject.toml"));
// Check that the source tree contains a module. // Check that the source tree contains a module.
let (src_root, modules_relative) = find_roots( let (src_root, module_relative) = find_roots(
source_tree, source_tree,
pyproject_toml, pyproject_toml,
&settings.module_root, &settings.module_root,
settings.module_name.as_ref(), settings.module_name.as_deref(),
settings.namespace, settings.namespace,
)?; )?;
for module_relative in modules_relative { // The wheel must not include any files included by the source distribution (at least until we
// The wheel must not include any files included by the source distribution (at least until we // have files generated in the source dist -> wheel build step).
// have files generated in the source dist -> wheel build step). let import_path = uv_fs::normalize_path(
let import_path = uv_fs::normalize_path( &uv_fs::relative_to(src_root.join(module_relative), source_tree)
&uv_fs::relative_to(src_root.join(module_relative), source_tree) .expect("module root is inside source tree"),
.expect("module root is inside source tree"), )
) .portable_display()
.portable_display() .to_string();
.to_string(); includes.push(format!("{}/**", globset::escape(&import_path)));
includes.push(format!("{}/**", globset::escape(&import_path)));
}
for include in includes { for include in includes {
let glob = PortableGlobParser::Uv let glob = PortableGlobParser::Uv
.parse(&include) .parse(&include)
@ -252,16 +250,32 @@ fn write_source_dist(
.expect("walkdir starts with root"); .expect("walkdir starts with root");
if !include_matcher.match_path(relative) || exclude_matcher.is_match(relative) { if !include_matcher.match_path(relative) || exclude_matcher.is_match(relative) {
trace!("Excluding from sdist: `{}`", relative.user_display()); trace!("Excluding: `{}`", relative.user_display());
continue; continue;
} }
let entry_path = Path::new(&top_level) debug!("Including {}", relative.user_display());
.join(relative) if entry.file_type().is_dir() {
.portable_display() writer.write_directory(
.to_string(); &Path::new(&top_level)
debug!("Adding to sdist: {}", relative.user_display()); .join(relative)
writer.write_dir_entry(&entry, &entry_path)?; .portable_display()
.to_string(),
)?;
} else if entry.file_type().is_file() {
writer.write_file(
&Path::new(&top_level)
.join(relative)
.portable_display()
.to_string(),
entry.path(),
)?;
} else {
return Err(Error::UnsupportedFileType(
relative.to_path_buf(),
entry.file_type(),
));
}
} }
debug!("Visited {files_visited} files for source dist build"); debug!("Visited {files_visited} files for source dist build");

View file

@ -1,7 +1,6 @@
use fs_err::File; use fs_err::File;
use globset::{GlobSet, GlobSetBuilder}; use globset::{GlobSet, GlobSetBuilder};
use itertools::Itertools; use itertools::Itertools;
use rustc_hash::FxHashSet;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::io::{BufReader, Read, Write}; use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -128,60 +127,65 @@ fn write_wheel(
source_tree, source_tree,
pyproject_toml, pyproject_toml,
&settings.module_root, &settings.module_root,
settings.module_name.as_ref(), settings.module_name.as_deref(),
settings.namespace, settings.namespace,
)?; )?;
// For convenience, have directories for the whole tree in the wheel
for ancestor in module_relative.ancestors().skip(1) {
if ancestor == Path::new("") {
continue;
}
wheel_writer.write_directory(&ancestor.portable_display().to_string())?;
}
let mut files_visited = 0; let mut files_visited = 0;
let mut prefix_directories = FxHashSet::default(); for entry in WalkDir::new(src_root.join(module_relative))
for module_relative in module_relative { .sort_by_file_name()
// For convenience, have directories for the whole tree in the wheel .into_iter()
for ancestor in module_relative.ancestors().skip(1) { .filter_entry(|entry| !exclude_matcher.is_match(entry.path()))
if ancestor == Path::new("") { {
continue; let entry = entry.map_err(|err| Error::WalkDir {
} root: source_tree.to_path_buf(),
// Avoid duplicate directories in the zip. err,
if prefix_directories.insert(ancestor.to_path_buf()) { })?;
wheel_writer.write_directory(&ancestor.portable_display().to_string())?;
} files_visited += 1;
if files_visited > 10000 {
warn_user_once!(
"Visited more than 10,000 files for wheel build. \
Consider using more constrained includes or more excludes."
);
} }
for entry in WalkDir::new(src_root.join(module_relative)) // We only want to take the module root, but since excludes start at the source tree root,
.sort_by_file_name() // we strip higher than we iterate.
.into_iter() let match_path = entry
.filter_entry(|entry| !exclude_matcher.is_match(entry.path())) .path()
{ .strip_prefix(source_tree)
let entry = entry.map_err(|err| Error::WalkDir { .expect("walkdir starts with root");
root: source_tree.to_path_buf(), let wheel_path = entry
err, .path()
})?; .strip_prefix(&src_root)
.expect("walkdir starts with root");
if exclude_matcher.is_match(match_path) {
trace!("Excluding from module: `{}`", match_path.user_display());
continue;
}
let wheel_path = wheel_path.portable_display().to_string();
files_visited += 1; debug!("Adding to wheel: `{wheel_path}`");
if files_visited > 10000 {
warn_user_once!(
"Visited more than 10,000 files for wheel build. \
Consider using more constrained includes or more excludes."
);
}
// We only want to take the module root, but since excludes start at the source tree root, if entry.file_type().is_dir() {
// we strip higher than we iterate. wheel_writer.write_directory(&wheel_path)?;
let match_path = entry } else if entry.file_type().is_file() {
.path() wheel_writer.write_file(&wheel_path, entry.path())?;
.strip_prefix(source_tree) } else {
.expect("walkdir starts with root"); // TODO(konsti): We may want to support symlinks, there is support for installing them.
let entry_path = entry return Err(Error::UnsupportedFileType(
.path() entry.path().to_path_buf(),
.strip_prefix(&src_root) entry.file_type(),
.expect("walkdir starts with root"); ));
if exclude_matcher.is_match(match_path) {
trace!("Excluding from module: `{}`", match_path.user_display());
continue;
}
let entry_path = entry_path.portable_display().to_string();
debug!("Adding to wheel: {entry_path}");
wheel_writer.write_dir_entry(&entry, &entry_path)?;
} }
} }
debug!("Visited {files_visited} files for wheel build"); debug!("Visited {files_visited} files for wheel build");
@ -276,7 +280,7 @@ pub fn build_editable(
source_tree, source_tree,
&pyproject_toml, &pyproject_toml,
&settings.module_root, &settings.module_root,
settings.module_name.as_ref(), settings.module_name.as_deref(),
settings.namespace, settings.namespace,
)?; )?;
@ -515,12 +519,23 @@ fn wheel_subdir_from_globs(
continue; continue;
} }
let license_path = Path::new(target) let relative_licenses = Path::new(target)
.join(relative) .join(relative)
.portable_display() .portable_display()
.to_string(); .to_string();
debug!("Adding for {}: `{}`", globs_field, relative.user_display());
wheel_writer.write_dir_entry(&entry, &license_path)?; if entry.file_type().is_dir() {
wheel_writer.write_directory(&relative_licenses)?;
} else if entry.file_type().is_file() {
debug!("Adding {} file: `{}`", globs_field, relative.user_display());
wheel_writer.write_file(&relative_licenses, entry.path())?;
} else {
// TODO(konsti): We may want to support symlinks, there is support for installing them.
return Err(Error::UnsupportedFileType(
entry.path().to_path_buf(),
entry.file_type(),
));
}
} }
Ok(()) Ok(())
} }

View file

@ -17,7 +17,6 @@ doctest = false
workspace = true workspace = true
[dependencies] [dependencies]
uv-cache-key = { workspace = true }
uv-configuration = { workspace = true } uv-configuration = { workspace = true }
uv-distribution = { workspace = true } uv-distribution = { workspace = true }
uv-distribution-types = { workspace = true } uv-distribution-types = { workspace = true }

View file

@ -25,14 +25,12 @@ use tempfile::TempDir;
use tokio::io::AsyncBufReadExt; use tokio::io::AsyncBufReadExt;
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::{Mutex, Semaphore}; use tokio::sync::{Mutex, Semaphore};
use tracing::{Instrument, debug, info_span, instrument, warn}; use tracing::{Instrument, debug, info_span, instrument};
use uv_cache_key::cache_digest;
use uv_configuration::PreviewMode; use uv_configuration::PreviewMode;
use uv_configuration::{BuildKind, BuildOutput, ConfigSettings, SourceStrategy}; use uv_configuration::{BuildKind, BuildOutput, ConfigSettings, SourceStrategy};
use uv_distribution::BuildRequires; use uv_distribution::BuildRequires;
use uv_distribution_types::{IndexLocations, Requirement, Resolution}; use uv_distribution_types::{IndexLocations, Requirement, Resolution};
use uv_fs::LockedFile;
use uv_fs::{PythonExt, Simplified}; use uv_fs::{PythonExt, Simplified};
use uv_pep440::Version; use uv_pep440::Version;
use uv_pep508::PackageName; use uv_pep508::PackageName;
@ -203,11 +201,6 @@ impl Pep517Backend {
{import} {import}
"#, backend_path = backend_path_encoded} "#, backend_path = backend_path_encoded}
} }
fn is_setuptools(&self) -> bool {
// either `setuptools.build_meta` or `setuptools.build_meta:__legacy__`
self.backend.split(':').next() == Some("setuptools.build_meta")
}
} }
/// Uses an [`Rc`] internally, clone freely. /// Uses an [`Rc`] internally, clone freely.
@ -441,31 +434,6 @@ impl SourceBuild {
}) })
} }
/// Acquire a lock on the source tree, if necessary.
async fn acquire_lock(&self) -> Result<Option<LockedFile>, Error> {
// Depending on the command, setuptools puts `*.egg-info`, `build/`, and `dist/` in the
// source tree, and concurrent invocations of setuptools using the same source dir can
// stomp on each other. We need to lock something to fix that, but we don't want to dump a
// `.lock` file into the source tree that the user will need to .gitignore. Take a global
// proxy lock instead.
let mut source_tree_lock = None;
if self.pep517_backend.is_setuptools() {
debug!("Locking the source tree for setuptools");
let canonical_source_path = self.source_tree.canonicalize()?;
let lock_path = env::temp_dir().join(format!(
"uv-setuptools-{}.lock",
cache_digest(&canonical_source_path)
));
source_tree_lock = LockedFile::acquire(lock_path, self.source_tree.to_string_lossy())
.await
.inspect_err(|err| {
warn!("Failed to acquire build lock: {err}");
})
.ok();
}
Ok(source_tree_lock)
}
async fn get_resolved_requirements( async fn get_resolved_requirements(
build_context: &impl BuildContext, build_context: &impl BuildContext,
source_build_context: SourceBuildContext, source_build_context: SourceBuildContext,
@ -636,9 +604,6 @@ impl SourceBuild {
return Ok(Some(metadata_dir.clone())); return Ok(Some(metadata_dir.clone()));
} }
// Lock the source tree, if necessary.
let _lock = self.acquire_lock().await?;
// Hatch allows for highly dynamic customization of metadata via hooks. In such cases, Hatch // Hatch allows for highly dynamic customization of metadata via hooks. In such cases, Hatch
// can't uphold the PEP 517 contract, in that the metadata Hatch would return by // can't uphold the PEP 517 contract, in that the metadata Hatch would return by
// `prepare_metadata_for_build_wheel` isn't guaranteed to match that of the built wheel. // `prepare_metadata_for_build_wheel` isn't guaranteed to match that of the built wheel.
@ -751,15 +716,16 @@ impl SourceBuild {
pub async fn build(&self, wheel_dir: &Path) -> Result<String, Error> { pub async fn build(&self, wheel_dir: &Path) -> Result<String, Error> {
// The build scripts run with the extracted root as cwd, so they need the absolute path. // The build scripts run with the extracted root as cwd, so they need the absolute path.
let wheel_dir = std::path::absolute(wheel_dir)?; let wheel_dir = std::path::absolute(wheel_dir)?;
let filename = self.pep517_build(&wheel_dir).await?; let filename = self.pep517_build(&wheel_dir, &self.pep517_backend).await?;
Ok(filename) Ok(filename)
} }
/// Perform a PEP 517 build for a wheel or source distribution (sdist). /// Perform a PEP 517 build for a wheel or source distribution (sdist).
async fn pep517_build(&self, output_dir: &Path) -> Result<String, Error> { async fn pep517_build(
// Lock the source tree, if necessary. &self,
let _lock = self.acquire_lock().await?; output_dir: &Path,
pep517_backend: &Pep517Backend,
) -> Result<String, Error> {
// Write the hook output to a file so that we can read it back reliably. // Write the hook output to a file so that we can read it back reliably.
let outfile = self let outfile = self
.temp_dir .temp_dir
@ -771,7 +737,7 @@ impl SourceBuild {
BuildKind::Sdist => { BuildKind::Sdist => {
debug!( debug!(
r#"Calling `{}.build_{}("{}", {})`"#, r#"Calling `{}.build_{}("{}", {})`"#,
self.pep517_backend.backend, pep517_backend.backend,
self.build_kind, self.build_kind,
output_dir.escape_for_python(), output_dir.escape_for_python(),
self.config_settings.escape_for_python(), self.config_settings.escape_for_python(),
@ -784,7 +750,7 @@ impl SourceBuild {
with open("{}", "w") as fp: with open("{}", "w") as fp:
fp.write(sdist_filename) fp.write(sdist_filename)
"#, "#,
self.pep517_backend.backend_import(), pep517_backend.backend_import(),
self.build_kind, self.build_kind,
output_dir.escape_for_python(), output_dir.escape_for_python(),
self.config_settings.escape_for_python(), self.config_settings.escape_for_python(),
@ -800,7 +766,7 @@ impl SourceBuild {
}); });
debug!( debug!(
r#"Calling `{}.build_{}("{}", {}, {})`"#, r#"Calling `{}.build_{}("{}", {}, {})`"#,
self.pep517_backend.backend, pep517_backend.backend,
self.build_kind, self.build_kind,
output_dir.escape_for_python(), output_dir.escape_for_python(),
self.config_settings.escape_for_python(), self.config_settings.escape_for_python(),
@ -814,7 +780,7 @@ impl SourceBuild {
with open("{}", "w") as fp: with open("{}", "w") as fp:
fp.write(wheel_filename) fp.write(wheel_filename)
"#, "#,
self.pep517_backend.backend_import(), pep517_backend.backend_import(),
self.build_kind, self.build_kind,
output_dir.escape_for_python(), output_dir.escape_for_python(),
self.config_settings.escape_for_python(), self.config_settings.escape_for_python(),
@ -844,7 +810,7 @@ impl SourceBuild {
return Err(Error::from_command_output( return Err(Error::from_command_output(
format!( format!(
"Call to `{}.build_{}` failed", "Call to `{}.build_{}` failed",
self.pep517_backend.backend, self.build_kind pep517_backend.backend, self.build_kind
), ),
&output, &output,
self.level, self.level,
@ -859,7 +825,7 @@ impl SourceBuild {
return Err(Error::from_command_output( return Err(Error::from_command_output(
format!( format!(
"Call to `{}.build_{}` failed", "Call to `{}.build_{}` failed",
self.pep517_backend.backend, self.build_kind pep517_backend.backend, self.build_kind
), ),
&output, &output,
self.level, self.level,

View file

@ -1,6 +1,6 @@
[package] [package]
name = "uv-build" name = "uv-build"
version = "0.7.20" version = "0.7.14"
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
homepage.workspace = true homepage.workspace = true

View file

@ -1,6 +1,6 @@
[project] [project]
name = "uv-build" name = "uv-build"
version = "0.7.20" version = "0.7.14"
description = "The uv build backend" description = "The uv build backend"
authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }]
requires-python = ">=3.8" requires-python = ">=3.8"

View file

@ -7,7 +7,6 @@ use serde::Deserialize;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::git_info::{Commit, Tags}; use crate::git_info::{Commit, Tags};
use crate::glob::cluster_globs;
use crate::timestamp::Timestamp; use crate::timestamp::Timestamp;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@ -213,39 +212,34 @@ impl CacheInfo {
} }
} }
// If we have any globs, first cluster them using LCP and then do a single pass on each group. // If we have any globs, process them in a single pass.
if !globs.is_empty() { if !globs.is_empty() {
for (glob_base, glob_patterns) in cluster_globs(&globs) { let walker = globwalk::GlobWalkerBuilder::from_patterns(directory, &globs)
let walker = globwalk::GlobWalkerBuilder::from_patterns(
directory.join(glob_base),
&glob_patterns,
)
.file_type(globwalk::FileType::FILE | globwalk::FileType::SYMLINK) .file_type(globwalk::FileType::FILE | globwalk::FileType::SYMLINK)
.build()?; .build()?;
for entry in walker { for entry in walker {
let entry = match entry { let entry = match entry {
Ok(entry) => entry, Ok(entry) => entry,
Err(err) => { Err(err) => {
warn!("Failed to read glob entry: {err}"); warn!("Failed to read glob entry: {err}");
continue;
}
};
let metadata = match entry.metadata() {
Ok(metadata) => metadata,
Err(err) => {
warn!("Failed to read metadata for glob entry: {err}");
continue;
}
};
if !metadata.is_file() {
warn!(
"Expected file for cache key, but found directory: `{}`",
entry.path().display()
);
continue; continue;
} }
timestamp = max(timestamp, Some(Timestamp::from_metadata(&metadata))); };
let metadata = match entry.metadata() {
Ok(metadata) => metadata,
Err(err) => {
warn!("Failed to read metadata for glob entry: {err}");
continue;
}
};
if !metadata.is_file() {
warn!(
"Expected file for cache key, but found directory: `{}`",
entry.path().display()
);
continue;
} }
timestamp = max(timestamp, Some(Timestamp::from_metadata(&metadata)));
} }
} }

View file

@ -1,318 +0,0 @@
use std::{
collections::BTreeMap,
path::{Component, Components, Path, PathBuf},
};
/// Check if a component of the path looks like it may be a glob pattern.
///
/// Note: this function is being used when splitting a glob pattern into a long possible
/// base and the glob remainder (scanning through components until we hit the first component
/// for which this function returns true). It is acceptable for this function to return
/// false positives (e.g. patterns like 'foo[bar' or 'foo{bar') in which case correctness
/// will not be affected but efficiency might be (because we'll traverse more than we should),
/// however it should not return false negatives.
fn is_glob_like(part: Component) -> bool {
matches!(part, Component::Normal(_))
&& part.as_os_str().to_str().is_some_and(|part| {
["*", "{", "}", "?", "[", "]"]
.into_iter()
.any(|c| part.contains(c))
})
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct GlobParts {
base: PathBuf,
pattern: PathBuf,
}
/// Split a glob into longest possible base + shortest possible glob pattern.
fn split_glob(pattern: impl AsRef<str>) -> GlobParts {
let pattern: &Path = pattern.as_ref().as_ref();
let mut glob = GlobParts::default();
let mut globbing = false;
let mut last = None;
for part in pattern.components() {
if let Some(last) = last {
if last != Component::CurDir {
if globbing {
glob.pattern.push(last);
} else {
glob.base.push(last);
}
}
}
if !globbing {
globbing = is_glob_like(part);
}
// we don't know if this part is the last one, defer handling it by one iteration
last = Some(part);
}
if let Some(last) = last {
// defer handling the last component to prevent draining entire pattern into base
if globbing || matches!(last, Component::Normal(_)) {
glob.pattern.push(last);
} else {
glob.base.push(last);
}
}
glob
}
/// Classic trie with edges being path components and values being glob patterns.
#[derive(Default)]
struct Trie<'a> {
children: BTreeMap<Component<'a>, Trie<'a>>,
patterns: Vec<&'a Path>,
}
impl<'a> Trie<'a> {
fn insert(&mut self, mut components: Components<'a>, pattern: &'a Path) {
if let Some(part) = components.next() {
self.children
.entry(part)
.or_default()
.insert(components, pattern);
} else {
self.patterns.push(pattern);
}
}
#[allow(clippy::needless_pass_by_value)]
fn collect_patterns(
&self,
pattern_prefix: PathBuf,
group_prefix: PathBuf,
patterns: &mut Vec<PathBuf>,
groups: &mut Vec<(PathBuf, Vec<PathBuf>)>,
) {
// collect all patterns beneath and including this node
for pattern in &self.patterns {
patterns.push(pattern_prefix.join(pattern));
}
for (part, child) in &self.children {
if let Component::Normal(_) = part {
// for normal components, collect all descendant patterns ('normal' edges only)
child.collect_patterns(
pattern_prefix.join(part),
group_prefix.join(part),
patterns,
groups,
);
} else {
// for non-normal component edges, kick off separate group collection at this node
child.collect_groups(group_prefix.join(part), groups);
}
}
}
#[allow(clippy::needless_pass_by_value)]
fn collect_groups(&self, prefix: PathBuf, groups: &mut Vec<(PathBuf, Vec<PathBuf>)>) {
// LCP-style grouping of patterns
if self.patterns.is_empty() {
// no patterns in this node; child nodes can form independent groups
for (part, child) in &self.children {
child.collect_groups(prefix.join(part), groups);
}
} else {
// pivot point, we've hit a pattern node; we have to stop here and form a group
let mut group = Vec::new();
self.collect_patterns(PathBuf::new(), prefix.clone(), &mut group, groups);
groups.push((prefix, group));
}
}
}
/// Given a collection of globs, cluster them into (base, globs) groups so that:
/// - base doesn't contain any glob symbols
/// - each directory would only be walked at most once
/// - base of each group is the longest common prefix of globs in the group
pub(crate) fn cluster_globs(patterns: &[impl AsRef<str>]) -> Vec<(PathBuf, Vec<String>)> {
// split all globs into base/pattern
let globs: Vec<_> = patterns.iter().map(split_glob).collect();
// construct a path trie out of all split globs
let mut trie = Trie::default();
for glob in &globs {
trie.insert(glob.base.components(), &glob.pattern);
}
// run LCP-style aggregation of patterns in the trie into groups
let mut groups = Vec::new();
trie.collect_groups(PathBuf::new(), &mut groups);
// finally, convert resulting patterns to strings
groups
.into_iter()
.map(|(base, patterns)| {
(
base,
patterns
.iter()
// NOTE: this unwrap is ok because input patterns are valid utf-8
.map(|p| p.to_str().unwrap().to_owned())
.collect(),
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{GlobParts, cluster_globs, split_glob};
fn windowsify(path: &str) -> String {
if cfg!(windows) {
path.replace('/', "\\")
} else {
path.to_owned()
}
}
#[test]
fn test_split_glob() {
#[track_caller]
fn check(input: &str, base: &str, pattern: &str) {
let result = split_glob(input);
let expected = GlobParts {
base: base.into(),
pattern: pattern.into(),
};
assert_eq!(result, expected, "{input:?} != {base:?} + {pattern:?}");
}
check("", "", "");
check("a", "", "a");
check("a/b", "a", "b");
check("a/b/", "a", "b");
check("a/.//b/", "a", "b");
check("./a/b/c", "a/b", "c");
check("c/d/*", "c/d", "*");
check("c/d/*/../*", "c/d", "*/../*");
check("a/?b/c", "a", "?b/c");
check("/a/b/*", "/a/b", "*");
check("../x/*", "../x", "*");
check("a/{b,c}/d", "a", "{b,c}/d");
check("a/[bc]/d", "a", "[bc]/d");
check("*", "", "*");
check("*/*", "", "*/*");
check("..", "..", "");
check("/", "/", "");
}
#[test]
fn test_cluster_globs() {
#[track_caller]
fn check(input: &[&str], expected: &[(&str, &[&str])]) {
let input = input.iter().map(|s| windowsify(s)).collect::<Vec<_>>();
let mut result_sorted = cluster_globs(&input);
for (_, patterns) in &mut result_sorted {
patterns.sort_unstable();
}
result_sorted.sort_unstable();
let mut expected_sorted = Vec::new();
for (base, patterns) in expected {
let mut patterns_sorted = Vec::new();
for pattern in *patterns {
patterns_sorted.push(windowsify(pattern));
}
patterns_sorted.sort_unstable();
expected_sorted.push((windowsify(base).into(), patterns_sorted));
}
expected_sorted.sort_unstable();
assert_eq!(
result_sorted, expected_sorted,
"{input:?} != {expected_sorted:?} (got: {result_sorted:?})"
);
}
check(&["a/b/*", "a/c/*"], &[("a/b", &["*"]), ("a/c", &["*"])]);
check(&["./a/b/*", "a/c/*"], &[("a/b", &["*"]), ("a/c", &["*"])]);
check(&["/a/b/*", "/a/c/*"], &[("/a/b", &["*"]), ("/a/c", &["*"])]);
check(
&["../a/b/*", "../a/c/*"],
&[("../a/b", &["*"]), ("../a/c", &["*"])],
);
check(&["x/*", "y/*"], &[("x", &["*"]), ("y", &["*"])]);
check(&[], &[]);
check(
&["./*", "a/*", "../foo/*.png"],
&[("", &["*", "a/*"]), ("../foo", &["*.png"])],
);
check(
&[
"?",
"/foo/?",
"/foo/bar/*",
"../bar/*.png",
"../bar/../baz/*.jpg",
],
&[
("", &["?"]),
("/foo", &["?", "bar/*"]),
("../bar", &["*.png"]),
("../bar/../baz", &["*.jpg"]),
],
);
check(&["/abs/path/*"], &[("/abs/path", &["*"])]);
check(&["/abs/*", "rel/*"], &[("/abs", &["*"]), ("rel", &["*"])]);
check(&["a/{b,c}/*", "a/d?/*"], &[("a", &["{b,c}/*", "d?/*"])]);
check(
&[
"../shared/a/[abc].png",
"../shared/a/b/*",
"../shared/b/c/?x/d",
"docs/important/*.{doc,xls}",
"docs/important/very/*",
],
&[
("../shared/a", &["[abc].png", "b/*"]),
("../shared/b/c", &["?x/d"]),
("docs/important", &["*.{doc,xls}", "very/*"]),
],
);
check(&["file.txt"], &[("", &["file.txt"])]);
check(&["/"], &[("/", &[""])]);
check(&[".."], &[("..", &[""])]);
check(
&["file1.txt", "file2.txt"],
&[("", &["file1.txt", "file2.txt"])],
);
check(
&["a/file1.txt", "a/file2.txt"],
&[("a", &["file1.txt", "file2.txt"])],
);
check(
&["*", "a/b/*", "a/../c/*.jpg", "a/../c/*.png", "/a/*", "/b/*"],
&[
("", &["*", "a/b/*"]),
("a/../c", &["*.jpg", "*.png"]),
("/a", &["*"]),
("/b", &["*"]),
],
);
if cfg!(windows) {
check(
&[
r"\\foo\bar\shared/a/[abc].png",
r"\\foo\bar\shared/a/b/*",
r"\\foo\bar/shared/b/c/?x/d",
r"D:\docs\important/*.{doc,xls}",
r"D:\docs/important/very/*",
],
&[
(r"\\foo\bar\shared\a", &["[abc].png", r"b\*"]),
(r"\\foo\bar\shared\b\c", &[r"?x\d"]),
(r"D:\docs\important", &["*.{doc,xls}", r"very\*"]),
],
);
}
}
}

View file

@ -3,5 +3,4 @@ pub use crate::timestamp::*;
mod cache_info; mod cache_info;
mod git_info; mod git_info;
mod glob;
mod timestamp; mod timestamp;

View file

@ -532,10 +532,8 @@ pub struct VersionArgs {
pub value: Option<String>, pub value: Option<String>,
/// Update the project version using the given semantics /// Update the project version using the given semantics
///
/// This flag can be passed multiple times.
#[arg(group = "operation", long)] #[arg(group = "operation", long)]
pub bump: Vec<VersionBump>, pub bump: Option<VersionBump>,
/// Don't write a new version to the `pyproject.toml` /// Don't write a new version to the `pyproject.toml`
/// ///
@ -610,56 +608,14 @@ pub struct VersionArgs {
pub python: Option<Maybe<String>>, pub python: Option<Maybe<String>>,
} }
// Note that the ordering of the variants is significant, as when given a list of operations #[derive(Debug, Copy, Clone, PartialEq, clap::ValueEnum)]
// to perform, we sort them and apply them in order, so users don't have to think too hard about it.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
pub enum VersionBump { pub enum VersionBump {
/// Increase the major version (e.g., 1.2.3 => 2.0.0) /// Increase the major version (1.2.3 => 2.0.0)
Major, Major,
/// Increase the minor version (e.g., 1.2.3 => 1.3.0) /// Increase the minor version (1.2.3 => 1.3.0)
Minor, Minor,
/// Increase the patch version (e.g., 1.2.3 => 1.2.4) /// Increase the patch version (1.2.3 => 1.2.4)
Patch, Patch,
/// Move from a pre-release to stable version (e.g., 1.2.3b4.post5.dev6 => 1.2.3)
///
/// Removes all pre-release components, but will not remove "local" components.
Stable,
/// Increase the alpha version (e.g., 1.2.3a4 => 1.2.3a5)
///
/// To move from a stable to a pre-release version, combine this with a stable component, e.g.,
/// for 1.2.3 => 2.0.0a1, you'd also include [`VersionBump::Major`].
Alpha,
/// Increase the beta version (e.g., 1.2.3b4 => 1.2.3b5)
///
/// To move from a stable to a pre-release version, combine this with a stable component, e.g.,
/// for 1.2.3 => 2.0.0b1, you'd also include [`VersionBump::Major`].
Beta,
/// Increase the rc version (e.g., 1.2.3rc4 => 1.2.3rc5)
///
/// To move from a stable to a pre-release version, combine this with a stable component, e.g.,
/// for 1.2.3 => 2.0.0rc1, you'd also include [`VersionBump::Major`].]
Rc,
/// Increase the post version (e.g., 1.2.3.post5 => 1.2.3.post6)
Post,
/// Increase the dev version (e.g., 1.2.3a4.dev6 => 1.2.3.dev7)
Dev,
}
impl std::fmt::Display for VersionBump {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let string = match self {
VersionBump::Major => "major",
VersionBump::Minor => "minor",
VersionBump::Patch => "patch",
VersionBump::Stable => "stable",
VersionBump::Alpha => "alpha",
VersionBump::Beta => "beta",
VersionBump::Rc => "rc",
VersionBump::Post => "post",
VersionBump::Dev => "dev",
};
string.fmt(f)
}
} }
#[derive(Args)] #[derive(Args)]
@ -3045,7 +3001,7 @@ pub struct RunArgs {
/// When used in a project, these dependencies will be layered on top of the project environment /// When used in a project, these dependencies will be layered on top of the project environment
/// in a separate, ephemeral environment. These dependencies are allowed to conflict with those /// in a separate, ephemeral environment. These dependencies are allowed to conflict with those
/// specified by the project. /// specified by the project.
#[arg(short = 'w', long)] #[arg(long)]
pub with: Vec<comma::CommaSeparatedRequirements>, pub with: Vec<comma::CommaSeparatedRequirements>,
/// Run with the given packages installed in editable mode. /// Run with the given packages installed in editable mode.
@ -3439,23 +3395,6 @@ pub struct SyncArgs {
)] )]
pub python: Option<Maybe<String>>, pub python: Option<Maybe<String>>,
/// The platform for which requirements should be installed.
///
/// Represented as a "target triple", a string that describes the target platform in terms of
/// its CPU, vendor, and operating system name, like `x86_64-unknown-linux-gnu` or
/// `aarch64-apple-darwin`.
///
/// When targeting macOS (Darwin), the default minimum version is `12.0`. Use
/// `MACOSX_DEPLOYMENT_TARGET` to specify a different minimum version, e.g., `13.0`.
///
/// WARNING: When specified, uv will select wheels that are compatible with the _target_
/// platform; as a result, the installed distributions may not be compatible with the _current_
/// platform. Conversely, any distributions that are built from source may be incompatible with
/// the _target_ platform, as they will be built for the _current_ platform. The
/// `--python-platform` option is intended for advanced use cases.
#[arg(long)]
pub python_platform: Option<TargetTriple>,
/// Check if the Python environment is synchronized with the project. /// Check if the Python environment is synchronized with the project.
/// ///
/// If the environment is not up to date, uv will exit with an error. /// If the environment is not up to date, uv will exit with an error.
@ -3693,8 +3632,7 @@ pub struct AddArgs {
long, long,
conflicts_with = "dev", conflicts_with = "dev",
conflicts_with = "optional", conflicts_with = "optional",
conflicts_with = "package", conflicts_with = "package"
conflicts_with = "workspace"
)] )]
pub script: Option<PathBuf>, pub script: Option<PathBuf>,
@ -3710,13 +3648,6 @@ pub struct AddArgs {
value_parser = parse_maybe_string, value_parser = parse_maybe_string,
)] )]
pub python: Option<Maybe<String>>, pub python: Option<Maybe<String>>,
/// Add the dependency as a workspace member.
///
/// When used with a path dependency, the package will be added to the workspace's `members`
/// list in the root `pyproject.toml` file.
#[arg(long)]
pub workspace: bool,
} }
#[derive(Args)] #[derive(Args)]
@ -4273,7 +4204,7 @@ pub struct ToolRunArgs {
pub from: Option<String>, pub from: Option<String>,
/// Run with the given packages installed. /// Run with the given packages installed.
#[arg(short = 'w', long)] #[arg(long)]
pub with: Vec<comma::CommaSeparatedRequirements>, pub with: Vec<comma::CommaSeparatedRequirements>,
/// Run with the given packages installed in editable mode /// Run with the given packages installed in editable mode
@ -4388,7 +4319,7 @@ pub struct ToolInstallArgs {
pub from: Option<String>, pub from: Option<String>,
/// Include the following additional requirements. /// Include the following additional requirements.
#[arg(short = 'w', long)] #[arg(long)]
pub with: Vec<comma::CommaSeparatedRequirements>, pub with: Vec<comma::CommaSeparatedRequirements>,
/// Include all requirements listed in the given `requirements.txt` files. /// Include all requirements listed in the given `requirements.txt` files.
@ -5199,9 +5130,6 @@ pub struct IndexArgs {
/// All indexes provided via this flag take priority over the index specified by /// All indexes provided via this flag take priority over the index specified by
/// `--default-index` (which defaults to PyPI). When multiple `--index` flags are provided, /// `--default-index` (which defaults to PyPI). When multiple `--index` flags are provided,
/// earlier values take priority. /// earlier values take priority.
///
/// Index names are not supported as values. Relative paths must be disambiguated from index
/// names with `./` or `../` on Unix or `.\\`, `..\\`, `./` or `../` on Windows.
// //
// The nested Vec structure (`Vec<Vec<Maybe<Index>>>`) is required for clap's // The nested Vec structure (`Vec<Vec<Maybe<Index>>>`) is required for clap's
// value parsing mechanism, which processes one value at a time, in order to handle // value parsing mechanism, which processes one value at a time, in order to handle

View file

@ -1,10 +1,7 @@
use anstream::eprintln;
use uv_cache::Refresh; use uv_cache::Refresh;
use uv_configuration::ConfigSettings; use uv_configuration::ConfigSettings;
use uv_resolver::PrereleaseMode; use uv_resolver::PrereleaseMode;
use uv_settings::{Combine, PipOptions, ResolverInstallerOptions, ResolverOptions}; use uv_settings::{Combine, PipOptions, ResolverInstallerOptions, ResolverOptions};
use uv_warnings::owo_colors::OwoColorize;
use crate::{ use crate::{
BuildOptionsArgs, FetchArgs, IndexArgs, InstallerArgs, Maybe, RefreshArgs, ResolverArgs, BuildOptionsArgs, FetchArgs, IndexArgs, InstallerArgs, Maybe, RefreshArgs, ResolverArgs,
@ -12,27 +9,12 @@ use crate::{
}; };
/// Given a boolean flag pair (like `--upgrade` and `--no-upgrade`), resolve the value of the flag. /// Given a boolean flag pair (like `--upgrade` and `--no-upgrade`), resolve the value of the flag.
pub fn flag(yes: bool, no: bool, name: &str) -> Option<bool> { pub fn flag(yes: bool, no: bool) -> Option<bool> {
match (yes, no) { match (yes, no) {
(true, false) => Some(true), (true, false) => Some(true),
(false, true) => Some(false), (false, true) => Some(false),
(false, false) => None, (false, false) => None,
(..) => { (..) => unreachable!("Clap should make this impossible"),
eprintln!(
"{}{} `{}` and `{}` cannot be used together. \
Boolean flags on different levels are currently not supported \
(https://github.com/clap-rs/clap/issues/6049)",
"error".bold().red(),
":".bold(),
format!("--{name}").green(),
format!("--no-{name}").green(),
);
// No error forwarding since should eventually be solved on the clap side.
#[allow(clippy::exit)]
{
std::process::exit(2);
}
}
} }
} }
@ -44,7 +26,7 @@ impl From<RefreshArgs> for Refresh {
refresh_package, refresh_package,
} = value; } = value;
Self::from_args(flag(refresh, no_refresh, "no-refresh"), refresh_package) Self::from_args(flag(refresh, no_refresh), refresh_package)
} }
} }
@ -71,7 +53,7 @@ impl From<ResolverArgs> for PipOptions {
} = args; } = args;
Self { Self {
upgrade: flag(upgrade, no_upgrade, "no-upgrade"), upgrade: flag(upgrade, no_upgrade),
upgrade_package: Some(upgrade_package), upgrade_package: Some(upgrade_package),
index_strategy, index_strategy,
keyring_provider, keyring_provider,
@ -84,7 +66,7 @@ impl From<ResolverArgs> for PipOptions {
}, },
config_settings: config_setting config_settings: config_setting
.map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()), .map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()),
no_build_isolation: flag(no_build_isolation, build_isolation, "build-isolation"), no_build_isolation: flag(no_build_isolation, build_isolation),
no_build_isolation_package: Some(no_build_isolation_package), no_build_isolation_package: Some(no_build_isolation_package),
exclude_newer, exclude_newer,
link_mode, link_mode,
@ -114,16 +96,16 @@ impl From<InstallerArgs> for PipOptions {
} = args; } = args;
Self { Self {
reinstall: flag(reinstall, no_reinstall, "reinstall"), reinstall: flag(reinstall, no_reinstall),
reinstall_package: Some(reinstall_package), reinstall_package: Some(reinstall_package),
index_strategy, index_strategy,
keyring_provider, keyring_provider,
config_settings: config_setting config_settings: config_setting
.map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()), .map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()),
no_build_isolation: flag(no_build_isolation, build_isolation, "build-isolation"), no_build_isolation: flag(no_build_isolation, build_isolation),
exclude_newer, exclude_newer,
link_mode, link_mode,
compile_bytecode: flag(compile_bytecode, no_compile_bytecode, "compile-bytecode"), compile_bytecode: flag(compile_bytecode, no_compile_bytecode),
no_sources: if no_sources { Some(true) } else { None }, no_sources: if no_sources { Some(true) } else { None },
..PipOptions::from(index_args) ..PipOptions::from(index_args)
} }
@ -158,9 +140,9 @@ impl From<ResolverInstallerArgs> for PipOptions {
} = args; } = args;
Self { Self {
upgrade: flag(upgrade, no_upgrade, "upgrade"), upgrade: flag(upgrade, no_upgrade),
upgrade_package: Some(upgrade_package), upgrade_package: Some(upgrade_package),
reinstall: flag(reinstall, no_reinstall, "reinstall"), reinstall: flag(reinstall, no_reinstall),
reinstall_package: Some(reinstall_package), reinstall_package: Some(reinstall_package),
index_strategy, index_strategy,
keyring_provider, keyring_provider,
@ -173,11 +155,11 @@ impl From<ResolverInstallerArgs> for PipOptions {
fork_strategy, fork_strategy,
config_settings: config_setting config_settings: config_setting
.map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()), .map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()),
no_build_isolation: flag(no_build_isolation, build_isolation, "build-isolation"), no_build_isolation: flag(no_build_isolation, build_isolation),
no_build_isolation_package: Some(no_build_isolation_package), no_build_isolation_package: Some(no_build_isolation_package),
exclude_newer, exclude_newer,
link_mode, link_mode,
compile_bytecode: flag(compile_bytecode, no_compile_bytecode, "compile-bytecode"), compile_bytecode: flag(compile_bytecode, no_compile_bytecode),
no_sources: if no_sources { Some(true) } else { None }, no_sources: if no_sources { Some(true) } else { None },
..PipOptions::from(index_args) ..PipOptions::from(index_args)
} }
@ -307,7 +289,7 @@ pub fn resolver_options(
.filter_map(Maybe::into_option) .filter_map(Maybe::into_option)
.collect() .collect()
}), }),
upgrade: flag(upgrade, no_upgrade, "no-upgrade"), upgrade: flag(upgrade, no_upgrade),
upgrade_package: Some(upgrade_package), upgrade_package: Some(upgrade_package),
index_strategy, index_strategy,
keyring_provider, keyring_provider,
@ -321,13 +303,13 @@ pub fn resolver_options(
dependency_metadata: None, dependency_metadata: None,
config_settings: config_setting config_settings: config_setting
.map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()), .map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()),
no_build_isolation: flag(no_build_isolation, build_isolation, "build-isolation"), no_build_isolation: flag(no_build_isolation, build_isolation),
no_build_isolation_package: Some(no_build_isolation_package), no_build_isolation_package: Some(no_build_isolation_package),
exclude_newer, exclude_newer,
link_mode, link_mode,
no_build: flag(no_build, build, "build"), no_build: flag(no_build, build),
no_build_package: Some(no_build_package), no_build_package: Some(no_build_package),
no_binary: flag(no_binary, binary, "binary"), no_binary: flag(no_binary, binary),
no_binary_package: Some(no_binary_package), no_binary_package: Some(no_binary_package),
no_sources: if no_sources { Some(true) } else { None }, no_sources: if no_sources { Some(true) } else { None },
} }
@ -404,13 +386,13 @@ pub fn resolver_installer_options(
.filter_map(Maybe::into_option) .filter_map(Maybe::into_option)
.collect() .collect()
}), }),
upgrade: flag(upgrade, no_upgrade, "upgrade"), upgrade: flag(upgrade, no_upgrade),
upgrade_package: if upgrade_package.is_empty() { upgrade_package: if upgrade_package.is_empty() {
None None
} else { } else {
Some(upgrade_package) Some(upgrade_package)
}, },
reinstall: flag(reinstall, no_reinstall, "reinstall"), reinstall: flag(reinstall, no_reinstall),
reinstall_package: if reinstall_package.is_empty() { reinstall_package: if reinstall_package.is_empty() {
None None
} else { } else {
@ -428,7 +410,7 @@ pub fn resolver_installer_options(
dependency_metadata: None, dependency_metadata: None,
config_settings: config_setting config_settings: config_setting
.map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()), .map(|config_settings| config_settings.into_iter().collect::<ConfigSettings>()),
no_build_isolation: flag(no_build_isolation, build_isolation, "build-isolation"), no_build_isolation: flag(no_build_isolation, build_isolation),
no_build_isolation_package: if no_build_isolation_package.is_empty() { no_build_isolation_package: if no_build_isolation_package.is_empty() {
None None
} else { } else {
@ -436,14 +418,14 @@ pub fn resolver_installer_options(
}, },
exclude_newer, exclude_newer,
link_mode, link_mode,
compile_bytecode: flag(compile_bytecode, no_compile_bytecode, "compile-bytecode"), compile_bytecode: flag(compile_bytecode, no_compile_bytecode),
no_build: flag(no_build, build, "build"), no_build: flag(no_build, build),
no_build_package: if no_build_package.is_empty() { no_build_package: if no_build_package.is_empty() {
None None
} else { } else {
Some(no_build_package) Some(no_build_package)
}, },
no_binary: flag(no_binary, binary, "binary"), no_binary: flag(no_binary, binary),
no_binary_package: if no_binary_package.is_empty() { no_binary_package: if no_binary_package.is_empty() {
None None
} else { } else {

View file

@ -6,7 +6,6 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use std::{env, io, iter}; use std::{env, io, iter};
use anyhow::Context;
use anyhow::anyhow; use anyhow::anyhow;
use http::{ use http::{
HeaderMap, HeaderName, HeaderValue, Method, StatusCode, HeaderMap, HeaderName, HeaderValue, Method, StatusCode,
@ -26,7 +25,6 @@ use tracing::{debug, trace};
use url::ParseError; use url::ParseError;
use url::Url; use url::Url;
use uv_auth::Credentials;
use uv_auth::{AuthMiddleware, Indexes}; use uv_auth::{AuthMiddleware, Indexes};
use uv_configuration::{KeyringProviderType, TrustedHost}; use uv_configuration::{KeyringProviderType, TrustedHost};
use uv_fs::Simplified; use uv_fs::Simplified;
@ -167,25 +165,6 @@ impl<'a> BaseClientBuilder<'a> {
self self
} }
/// Read the retry count from [`EnvVars::UV_HTTP_RETRIES`] if set, otherwise, make no change.
///
/// Errors when [`EnvVars::UV_HTTP_RETRIES`] is not a valid u32.
pub fn retries_from_env(self) -> anyhow::Result<Self> {
// TODO(zanieb): We should probably parse this in another layer, but there's not a natural
// fit for it right now
if let Some(value) = env::var_os(EnvVars::UV_HTTP_RETRIES) {
Ok(self.retries(
value
.to_string_lossy()
.as_ref()
.parse::<u32>()
.context("Failed to parse `UV_HTTP_RETRIES`")?,
))
} else {
Ok(self)
}
}
#[must_use] #[must_use]
pub fn native_tls(mut self, native_tls: bool) -> Self { pub fn native_tls(mut self, native_tls: bool) -> Self {
self.native_tls = native_tls; self.native_tls = native_tls;
@ -258,11 +237,7 @@ impl<'a> BaseClientBuilder<'a> {
/// Create a [`RetryPolicy`] for the client. /// Create a [`RetryPolicy`] for the client.
fn retry_policy(&self) -> ExponentialBackoff { fn retry_policy(&self) -> ExponentialBackoff {
let mut builder = ExponentialBackoff::builder(); ExponentialBackoff::builder().build_with_max_retries(self.retries)
if env::var_os(EnvVars::UV_TEST_NO_HTTP_RETRY_DELAY).is_some() {
builder = builder.retry_bounds(Duration::from_millis(0), Duration::from_millis(0));
}
builder.build_with_max_retries(self.retries)
} }
pub fn build(&self) -> BaseClient { pub fn build(&self) -> BaseClient {
@ -750,16 +725,6 @@ fn request_into_redirect(
} }
} }
// Check if there are credentials on the redirect location itself.
// If so, move them to Authorization header.
if !redirect_url.username().is_empty() {
if let Some(credentials) = Credentials::from_url(&redirect_url) {
let _ = redirect_url.set_username("");
let _ = redirect_url.set_password(None);
headers.insert(AUTHORIZATION, credentials.to_header_value());
}
}
std::mem::swap(req.headers_mut(), &mut headers); std::mem::swap(req.headers_mut(), &mut headers);
*req.url_mut() = Url::from(redirect_url); *req.url_mut() = Url::from(redirect_url);
debug!( debug!(
@ -1006,45 +971,6 @@ mod tests {
Ok(()) Ok(())
} }
#[tokio::test]
async fn test_redirect_preserves_fragment() -> Result<()> {
for status in &[301, 302, 303, 307, 308] {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(
ResponseTemplate::new(*status)
.insert_header("location", format!("{}/redirect", server.uri())),
)
.mount(&server)
.await;
let request = Client::new()
.get(format!("{}#fragment", server.uri()))
.build()
.unwrap();
let response = Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap()
.execute(request.try_clone().unwrap())
.await
.unwrap();
let redirect_request =
request_into_redirect(request, &response, CrossOriginCredentialsPolicy::Secure)?
.unwrap();
assert!(
redirect_request
.url()
.fragment()
.is_some_and(|fragment| fragment == "fragment")
);
}
Ok(())
}
#[tokio::test] #[tokio::test]
async fn test_redirect_removes_authorization_header_on_cross_origin() -> Result<()> { async fn test_redirect_removes_authorization_header_on_cross_origin() -> Result<()> {
for status in &[301, 302, 303, 307, 308] { for status in &[301, 302, 303, 307, 308] {

View file

@ -152,6 +152,9 @@ pub enum ErrorKind {
#[error(transparent)] #[error(transparent)]
InvalidUrl(#[from] uv_distribution_types::ToUrlError), InvalidUrl(#[from] uv_distribution_types::ToUrlError),
#[error(transparent)]
JoinRelativeUrl(#[from] uv_pypi_types::JoinRelativeError),
#[error(transparent)] #[error(transparent)]
Flat(#[from] FlatIndexError), Flat(#[from] FlatIndexError),

View file

@ -21,8 +21,8 @@ use uv_configuration::KeyringProviderType;
use uv_configuration::{IndexStrategy, TrustedHost}; use uv_configuration::{IndexStrategy, TrustedHost};
use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename}; use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename};
use uv_distribution_types::{ use uv_distribution_types::{
BuiltDist, File, IndexCapabilities, IndexFormat, IndexLocations, IndexMetadataRef, BuiltDist, File, FileLocation, IndexCapabilities, IndexFormat, IndexLocations,
IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, IndexUrls, Name, IndexMetadataRef, IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, IndexUrls, Name,
}; };
use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream}; use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream};
use uv_normalize::PackageName; use uv_normalize::PackageName;
@ -115,11 +115,6 @@ impl<'a> RegistryClientBuilder<'a> {
self self
} }
pub fn retries_from_env(mut self) -> anyhow::Result<Self> {
self.base_client_builder = self.base_client_builder.retries_from_env()?;
Ok(self)
}
#[must_use] #[must_use]
pub fn native_tls(mut self, native_tls: bool) -> Self { pub fn native_tls(mut self, native_tls: bool) -> Self {
self.base_client_builder = self.base_client_builder.native_tls(native_tls); self.base_client_builder = self.base_client_builder.native_tls(native_tls);
@ -687,14 +682,30 @@ impl RegistryClient {
let wheel = wheels.best_wheel(); let wheel = wheels.best_wheel();
let url = wheel.file.url.to_url().map_err(ErrorKind::InvalidUrl)?; let location = match &wheel.file.url {
let location = if url.scheme() == "file" { FileLocation::RelativeUrl(base, url) => {
let path = url let url = uv_pypi_types::base_url_join_relative(base, url)
.to_file_path() .map_err(ErrorKind::JoinRelativeUrl)?;
.map_err(|()| ErrorKind::NonFileUrl(url.clone()))?; if url.scheme() == "file" {
WheelLocation::Path(path) let path = url
} else { .to_file_path()
WheelLocation::Url(url) .map_err(|()| ErrorKind::NonFileUrl(url.clone()))?;
WheelLocation::Path(path)
} else {
WheelLocation::Url(url)
}
}
FileLocation::AbsoluteUrl(url) => {
let url = url.to_url().map_err(ErrorKind::InvalidUrl)?;
if url.scheme() == "file" {
let path = url
.to_file_path()
.map_err(|()| ErrorKind::NonFileUrl(url.clone()))?;
WheelLocation::Path(path)
} else {
WheelLocation::Url(url)
}
}
}; };
match location { match location {
@ -1222,18 +1233,17 @@ mod tests {
use url::Url; use url::Url;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_pypi_types::SimpleJson; use uv_pypi_types::{JoinRelativeError, SimpleJson};
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
use crate::{SimpleMetadata, SimpleMetadatum, html::SimpleHtml}; use crate::{SimpleMetadata, SimpleMetadatum, html::SimpleHtml};
use crate::RegistryClientBuilder;
use uv_cache::Cache; use uv_cache::Cache;
use uv_distribution_types::{FileLocation, ToUrlError};
use uv_small_str::SmallString;
use wiremock::matchers::{basic_auth, method, path_regex}; use wiremock::matchers::{basic_auth, method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate}; use wiremock::{Mock, MockServer, ResponseTemplate};
use crate::RegistryClientBuilder;
type Error = Box<dyn std::error::Error>; type Error = Box<dyn std::error::Error>;
async fn start_test_server(username: &'static str, password: &'static str) -> MockServer { async fn start_test_server(username: &'static str, password: &'static str) -> MockServer {
@ -1406,6 +1416,44 @@ mod tests {
Ok(()) Ok(())
} }
#[tokio::test]
async fn test_redirect_preserve_fragment() -> Result<(), Error> {
let redirect_server = MockServer::start().await;
// Configure the redirect server to respond with a 307 with a relative URL.
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(307).insert_header("Location", "/foo".to_string()))
.mount(&redirect_server)
.await;
Mock::given(method("GET"))
.and(path_regex("/foo"))
.respond_with(ResponseTemplate::new(200))
.mount(&redirect_server)
.await;
let cache = Cache::temp()?;
let registry_client = RegistryClientBuilder::new(cache).build();
let client = registry_client.cached_client().uncached();
let mut url = DisplaySafeUrl::parse(&redirect_server.uri())?;
url.set_fragment(Some("fragment"));
assert_eq!(
client
.for_host(&url)
.get(Url::from(url.clone()))
.send()
.await?
.url()
.to_string(),
format!("{}/foo#fragment", redirect_server.uri()),
"Requests should preserve fragment"
);
Ok(())
}
#[test] #[test]
fn ignore_failing_files() { fn ignore_failing_files() {
// 1.7.7 has an invalid requires-python field (double comma), 1.7.8 is valid // 1.7.7 has an invalid requires-python field (double comma), 1.7.8 is valid
@ -1459,7 +1507,7 @@ mod tests {
/// ///
/// See: <https://github.com/astral-sh/uv/issues/1388> /// See: <https://github.com/astral-sh/uv/issues/1388>
#[test] #[test]
fn relative_urls_code_artifact() -> Result<(), ToUrlError> { fn relative_urls_code_artifact() -> Result<(), JoinRelativeError> {
let text = r#" let text = r#"
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@ -1482,13 +1530,12 @@ mod tests {
let base = DisplaySafeUrl::parse("https://account.d.codeartifact.us-west-2.amazonaws.com/pypi/shared-packages-pypi/simple/flask") let base = DisplaySafeUrl::parse("https://account.d.codeartifact.us-west-2.amazonaws.com/pypi/shared-packages-pypi/simple/flask")
.unwrap(); .unwrap();
let SimpleHtml { base, files } = SimpleHtml::parse(text, &base).unwrap(); let SimpleHtml { base, files } = SimpleHtml::parse(text, &base).unwrap();
let base = SmallString::from(base.as_str());
// Test parsing of the file urls // Test parsing of the file urls
let urls = files let urls = files
.into_iter() .iter()
.map(|file| FileLocation::new(file.url, &base).to_url()) .map(|file| uv_pypi_types::base_url_join_relative(base.as_url().as_str(), &file.url))
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, JoinRelativeError>>()?;
let urls = urls let urls = urls
.iter() .iter()
.map(DisplaySafeUrl::to_string) .map(DisplaySafeUrl::to_string)

View file

@ -4,7 +4,7 @@ use uv_pep508::PackageName;
use crate::{PackageNameSpecifier, PackageNameSpecifiers}; use crate::{PackageNameSpecifier, PackageNameSpecifiers};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum BuildKind { pub enum BuildKind {
/// A PEP 517 wheel build. /// A PEP 517 wheel build.
#[default] #[default]

View file

@ -1,5 +1,3 @@
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::str::FromStr; use std::str::FromStr;
use uv_pep508::PackageName; use uv_pep508::PackageName;
@ -65,16 +63,28 @@ impl<'de> serde::Deserialize<'de> for PackageNameSpecifier {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for PackageNameSpecifier { impl schemars::JsonSchema for PackageNameSpecifier {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("PackageNameSpecifier") "PackageNameSpecifier".to_string()
} }
fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ schemars::schema::SchemaObject {
"type": "string", instance_type: Some(schemars::schema::InstanceType::String.into()),
"pattern": r"^(:none:|:all:|([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]))$", string: Some(Box::new(schemars::schema::StringValidation {
"description": "The name of a package, or `:all:` or `:none:` to select or omit all packages, respectively.", // See: https://packaging.python.org/en/latest/specifications/name-normalization/#name-format
}) pattern: Some(
r"^(:none:|:all:|([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]))$"
.to_string(),
),
..schemars::schema::StringValidation::default()
})),
metadata: Some(Box::new(schemars::schema::Metadata {
description: Some("The name of a package, or `:all:` or `:none:` to select or omit all packages, respectively.".to_string()),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
} }
} }

View file

@ -1,6 +1,5 @@
#[cfg(feature = "schemars")] use std::fmt::Formatter;
use std::borrow::Cow; use std::str::FromStr;
use std::{fmt::Formatter, str::FromStr};
use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers, VersionSpecifiersParseError}; use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers, VersionSpecifiersParseError};
@ -37,15 +36,20 @@ impl FromStr for RequiredVersion {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for RequiredVersion { impl schemars::JsonSchema for RequiredVersion {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("RequiredVersion") String::from("RequiredVersion")
} }
fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ schemars::schema::SchemaObject {
"type": "string", instance_type: Some(schemars::schema::InstanceType::String.into()),
"description": "A version specifier, e.g. `>=0.5.0` or `==0.5.0`." metadata: Some(Box::new(schemars::schema::Metadata {
}) description: Some("A version specifier, e.g. `>=0.5.0` or `==0.5.0`.".to_string()),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
} }
} }

View file

@ -1,6 +1,4 @@
#[derive( #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)] #[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum SourceStrategy { pub enum SourceStrategy {
/// Use `tool.uv.sources` when resolving dependencies. /// Use `tool.uv.sources` when resolving dependencies.

View file

@ -227,7 +227,7 @@ pub enum TargetTriple {
#[serde(alias = "aarch64-manylinux240")] #[serde(alias = "aarch64-manylinux240")]
Aarch64Manylinux240, Aarch64Manylinux240,
/// A wasm32 target using the Pyodide 2024 platform. Meant for use with Python 3.12. /// A wasm32 target using the the Pyodide 2024 platform. Meant for use with Python 3.12.
#[cfg_attr(feature = "clap", value(name = "wasm32-pyodide2024"))] #[cfg_attr(feature = "clap", value(name = "wasm32-pyodide2024"))]
Wasm32Pyodide2024, Wasm32Pyodide2024,
} }

View file

@ -62,7 +62,7 @@ pub static RAYON_PARALLELISM: AtomicUsize = AtomicUsize::new(0);
/// `LazyLock::force(&RAYON_INITIALIZE)`. /// `LazyLock::force(&RAYON_INITIALIZE)`.
pub static RAYON_INITIALIZE: LazyLock<()> = LazyLock::new(|| { pub static RAYON_INITIALIZE: LazyLock<()> = LazyLock::new(|| {
rayon::ThreadPoolBuilder::new() rayon::ThreadPoolBuilder::new()
.num_threads(RAYON_PARALLELISM.load(Ordering::Relaxed)) .num_threads(RAYON_PARALLELISM.load(Ordering::SeqCst))
.stack_size(min_stack_size()) .stack_size(min_stack_size())
.build_global() .build_global()
.expect("failed to initialize global rayon pool"); .expect("failed to initialize global rayon pool");

View file

@ -1,6 +1,4 @@
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::str::FromStr; use std::str::FromStr;
use url::Url; use url::Url;
@ -145,15 +143,20 @@ impl std::fmt::Display for TrustedHost {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for TrustedHost { impl schemars::JsonSchema for TrustedHost {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("TrustedHost") "TrustedHost".to_string()
} }
fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ schemars::schema::SchemaObject {
"type": "string", instance_type: Some(schemars::schema::InstanceType::String.into()),
"description": "A host or host-port pair." metadata: Some(Box::new(schemars::schema::Metadata {
}) description: Some("A host or host-port pair.".to_string()),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
} }
} }

View file

@ -3,7 +3,7 @@ use std::path::PathBuf;
use anstream::println; use anstream::println;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use pretty_assertions::StrComparison; use pretty_assertions::StrComparison;
use schemars::JsonSchema; use schemars::{JsonSchema, schema_for};
use serde::Deserialize; use serde::Deserialize;
use uv_settings::Options as SettingsOptions; use uv_settings::Options as SettingsOptions;
@ -91,10 +91,7 @@ const REPLACEMENTS: &[(&str, &str)] = &[
/// Generate the JSON schema for the combined options as a string. /// Generate the JSON schema for the combined options as a string.
fn generate() -> String { fn generate() -> String {
let settings = schemars::generate::SchemaSettings::draft07(); let schema = schema_for!(CombinedOptions);
let generator = schemars::SchemaGenerator::new(settings);
let schema = generator.into_root_schema_for::<CombinedOptions>();
let mut output = serde_json::to_string_pretty(&schema).unwrap(); let mut output = serde_json::to_string_pretty(&schema).unwrap();
for (value, replacement) in REPLACEMENTS { for (value, replacement) in REPLACEMENTS {

View file

@ -11,7 +11,7 @@ use crate::ROOT_DIR;
use crate::generate_all::Mode; use crate::generate_all::Mode;
/// Contains current supported targets /// Contains current supported targets
const TARGETS_YML_URL: &str = "https://raw.githubusercontent.com/astral-sh/python-build-standalone/refs/tags/20250708/cpython-unix/targets.yml"; const TARGETS_YML_URL: &str = "https://raw.githubusercontent.com/astral-sh/python-build-standalone/refs/tags/20250612/cpython-unix/targets.yml";
#[derive(clap::Args)] #[derive(clap::Args)]
pub(crate) struct Args { pub(crate) struct Args {
@ -130,7 +130,7 @@ async fn generate() -> Result<String> {
output.push_str("//! DO NOT EDIT\n"); output.push_str("//! DO NOT EDIT\n");
output.push_str("//!\n"); output.push_str("//!\n");
output.push_str("//! Generated with `cargo run dev generate-sysconfig-metadata`\n"); output.push_str("//! Generated with `cargo run dev generate-sysconfig-metadata`\n");
output.push_str("//! Targets from <https://github.com/astral-sh/python-build-standalone/blob/20250708/cpython-unix/targets.yml>\n"); output.push_str("//! Targets from <https://github.com/astral-sh/python-build-standalone/blob/20250612/cpython-unix/targets.yml>\n");
output.push_str("//!\n"); output.push_str("//!\n");
// Disable clippy/fmt // Disable clippy/fmt

View file

@ -11,7 +11,6 @@ use itertools::Itertools;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use thiserror::Error; use thiserror::Error;
use tracing::{debug, instrument, trace}; use tracing::{debug, instrument, trace};
use uv_build_backend::check_direct_build; use uv_build_backend::check_direct_build;
use uv_build_frontend::{SourceBuild, SourceBuildContext}; use uv_build_frontend::{SourceBuild, SourceBuildContext};
use uv_cache::Cache; use uv_cache::Cache;
@ -36,8 +35,8 @@ use uv_resolver::{
PythonRequirement, Resolver, ResolverEnvironment, PythonRequirement, Resolver, ResolverEnvironment,
}; };
use uv_types::{ use uv_types::{
AnyErrorBuild, BuildArena, BuildContext, BuildIsolation, BuildStack, EmptyInstalledPackages, AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, EmptyInstalledPackages, HashStrategy,
HashStrategy, InFlight, InFlight,
}; };
use uv_workspace::WorkspaceCache; use uv_workspace::WorkspaceCache;
@ -180,10 +179,6 @@ impl BuildContext for BuildDispatch<'_> {
&self.shared_state.git &self.shared_state.git
} }
fn build_arena(&self) -> &BuildArena<SourceBuild> {
&self.shared_state.build_arena
}
fn capabilities(&self) -> &IndexCapabilities { fn capabilities(&self) -> &IndexCapabilities {
&self.shared_state.capabilities &self.shared_state.capabilities
} }
@ -453,6 +448,12 @@ impl BuildContext for BuildDispatch<'_> {
build_kind: BuildKind, build_kind: BuildKind,
version_id: Option<&'data str>, version_id: Option<&'data str>,
) -> Result<Option<DistFilename>, BuildDispatchError> { ) -> Result<Option<DistFilename>, BuildDispatchError> {
// Direct builds are a preview feature with the uv build backend.
if self.preview.is_disabled() {
trace!("Preview is disabled, not checking for direct build");
return Ok(None);
}
let source_tree = if let Some(subdir) = subdirectory { let source_tree = if let Some(subdir) = subdirectory {
source.join(subdir) source.join(subdir)
} else { } else {
@ -520,8 +521,6 @@ pub struct SharedState {
index: InMemoryIndex, index: InMemoryIndex,
/// The downloaded distributions. /// The downloaded distributions.
in_flight: InFlight, in_flight: InFlight,
/// Build directories for any PEP 517 builds executed during resolution or installation.
build_arena: BuildArena<SourceBuild>,
} }
impl SharedState { impl SharedState {
@ -534,7 +533,6 @@ impl SharedState {
Self { Self {
git: self.git.clone(), git: self.git.clone(),
capabilities: self.capabilities.clone(), capabilities: self.capabilities.clone(),
build_arena: self.build_arena.clone(),
..Default::default() ..Default::default()
} }
} }
@ -558,9 +556,4 @@ impl SharedState {
pub fn capabilities(&self) -> &IndexCapabilities { pub fn capabilities(&self) -> &IndexCapabilities {
&self.capabilities &self.capabilities
} }
/// Return the [`BuildArena`] used by the [`SharedState`].
pub fn build_arena(&self) -> &BuildArena<SourceBuild> {
&self.build_arena
}
} }

View file

@ -27,6 +27,7 @@ rkyv = { workspace = true, features = ["smallvec-1"] }
serde = { workspace = true } serde = { workspace = true }
smallvec = { workspace = true } smallvec = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
url = { workspace = true }
[dev-dependencies] [dev-dependencies]
insta = { version = "1.40.0" } insta = { version = "1.40.0" }

View file

@ -5,6 +5,7 @@ use std::str::FromStr;
use memchr::memchr; use memchr::memchr;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use thiserror::Error; use thiserror::Error;
use url::Url;
use uv_cache_key::cache_digest; use uv_cache_key::cache_digest;
use uv_normalize::{InvalidNameError, PackageName}; use uv_normalize::{InvalidNameError, PackageName};
@ -299,6 +300,29 @@ impl WheelFilename {
} }
} }
impl TryFrom<&Url> for WheelFilename {
type Error = WheelFilenameError;
fn try_from(url: &Url) -> Result<Self, Self::Error> {
let filename = url
.path_segments()
.ok_or_else(|| {
WheelFilenameError::InvalidWheelFileName(
url.to_string(),
"URL must have a path".to_string(),
)
})?
.next_back()
.ok_or_else(|| {
WheelFilenameError::InvalidWheelFileName(
url.to_string(),
"URL must contain a filename".to_string(),
)
})?;
Self::from_str(filename)
}
}
impl<'de> Deserialize<'de> for WheelFilename { impl<'de> Deserialize<'de> for WheelFilename {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where

View file

@ -29,7 +29,6 @@ uv-platform-tags = { workspace = true }
uv-pypi-types = { workspace = true } uv-pypi-types = { workspace = true }
uv-redacted = { workspace = true } uv-redacted = { workspace = true }
uv-small-str = { workspace = true } uv-small-str = { workspace = true }
uv-warnings = { workspace = true }
arcstr = { workspace = true } arcstr = { workspace = true }
bitflags = { workspace = true } bitflags = { workspace = true }

View file

@ -1,4 +1,3 @@
use std::borrow::Cow;
use std::fmt::{self, Display, Formatter}; use std::fmt::{self, Display, Formatter};
use std::str::FromStr; use std::str::FromStr;
@ -57,7 +56,10 @@ impl File {
.map_err(|err| FileConversionError::RequiresPython(err.line().clone(), err))?, .map_err(|err| FileConversionError::RequiresPython(err.line().clone(), err))?,
size: file.size, size: file.size,
upload_time_utc_ms: file.upload_time.map(Timestamp::as_millisecond), upload_time_utc_ms: file.upload_time.map(Timestamp::as_millisecond),
url: FileLocation::new(file.url, base), url: match split_scheme(&file.url) {
Some(..) => FileLocation::AbsoluteUrl(UrlString::new(file.url)),
None => FileLocation::RelativeUrl(base.clone(), file.url),
},
yanked: file.yanked, yanked: file.yanked,
}) })
} }
@ -74,17 +76,6 @@ pub enum FileLocation {
} }
impl FileLocation { impl FileLocation {
/// Parse a relative or absolute URL on a page with a base URL.
///
/// This follows the HTML semantics where a link on a page is resolved relative to the URL of
/// that page.
pub fn new(url: SmallString, base: &SmallString) -> Self {
match split_scheme(&url) {
Some(..) => FileLocation::AbsoluteUrl(UrlString::new(url)),
None => FileLocation::RelativeUrl(base.clone(), url),
}
}
/// Convert this location to a URL. /// Convert this location to a URL.
/// ///
/// A relative URL has its base joined to the path. An absolute URL is /// A relative URL has its base joined to the path. An absolute URL is
@ -169,13 +160,16 @@ impl UrlString {
.unwrap_or(self.as_ref()) .unwrap_or(self.as_ref())
} }
/// Return the [`UrlString`] (as a [`Cow`]) with any fragments removed. /// Return the [`UrlString`] with any fragments removed.
#[must_use] #[must_use]
pub fn without_fragment(&self) -> Cow<'_, Self> { pub fn without_fragment(&self) -> Self {
self.as_ref() Self(
.split_once('#') self.as_ref()
.map(|(path, _)| Cow::Owned(UrlString(SmallString::from(path)))) .split_once('#')
.unwrap_or(Cow::Borrowed(self)) .map(|(path, _)| path)
.map(SmallString::from)
.unwrap_or_else(|| self.0.clone()),
)
} }
} }
@ -258,17 +252,16 @@ mod tests {
#[test] #[test]
fn without_fragment() { fn without_fragment() {
// Borrows a URL without a fragment
let url = UrlString("https://example.com/path".into());
assert_eq!(&*url.without_fragment(), &url);
assert!(matches!(url.without_fragment(), Cow::Borrowed(_)));
// Removes the fragment if present on the URL
let url = UrlString("https://example.com/path?query#fragment".into()); let url = UrlString("https://example.com/path?query#fragment".into());
assert_eq!( assert_eq!(
&*url.without_fragment(), url.without_fragment(),
&UrlString("https://example.com/path?query".into()) UrlString("https://example.com/path?query".into())
); );
assert!(matches!(url.without_fragment(), Cow::Owned(_)));
let url = UrlString("https://example.com/path#fragment".into());
assert_eq!(url.base_str(), "https://example.com/path");
let url = UrlString("https://example.com/path".into());
assert_eq!(url.base_str(), "https://example.com/path");
} }
} }

View file

@ -12,7 +12,6 @@ use url::{ParseError, Url};
use uv_pep508::{Scheme, VerbatimUrl, VerbatimUrlError, split_scheme}; use uv_pep508::{Scheme, VerbatimUrl, VerbatimUrlError, split_scheme};
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
use uv_warnings::warn_user;
use crate::{Index, IndexStatusCodeStrategy, Verbatim}; use crate::{Index, IndexStatusCodeStrategy, Verbatim};
@ -93,15 +92,20 @@ impl IndexUrl {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for IndexUrl { impl schemars::JsonSchema for IndexUrl {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("IndexUrl") "IndexUrl".to_string()
} }
fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ schemars::schema::SchemaObject {
"type": "string", instance_type: Some(schemars::schema::InstanceType::String.into()),
"description": "The URL of an index to use for fetching packages (e.g., `https://pypi.org/simple`), or a local path." metadata: Some(Box::new(schemars::schema::Metadata {
}) description: Some("The URL of an index to use for fetching packages (e.g., `https://pypi.org/simple`), or a local path.".to_string()),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
} }
} }
@ -136,30 +140,6 @@ impl IndexUrl {
Cow::Owned(url) Cow::Owned(url)
} }
} }
/// Warn user if the given URL was provided as an ambiguous relative path.
///
/// This is a temporary warning. Ambiguous values will not be
/// accepted in the future.
pub fn warn_on_disambiguated_relative_path(&self) {
let Self::Path(verbatim_url) = &self else {
return;
};
if let Some(path) = verbatim_url.given() {
if !is_disambiguated_path(path) {
if cfg!(windows) {
warn_user!(
"Relative paths passed to `--index` or `--default-index` should be disambiguated from index names (use `.\\{path}` or `./{path}`). Support for ambiguous values will be removed in the future"
);
} else {
warn_user!(
"Relative paths passed to `--index` or `--default-index` should be disambiguated from index names (use `./{path}`). Support for ambiguous values will be removed in the future"
);
}
}
}
}
} }
impl Display for IndexUrl { impl Display for IndexUrl {
@ -182,28 +162,6 @@ impl Verbatim for IndexUrl {
} }
} }
/// Checks if a path is disambiguated.
///
/// Disambiguated paths are absolute paths, paths with valid schemes,
/// and paths starting with "./" or "../" on Unix or ".\\", "..\\",
/// "./", or "../" on Windows.
fn is_disambiguated_path(path: &str) -> bool {
if cfg!(windows) {
if path.starts_with(".\\") || path.starts_with("..\\") || path.starts_with('/') {
return true;
}
}
if path.starts_with("./") || path.starts_with("../") || Path::new(path).is_absolute() {
return true;
}
// Check if the path has a scheme (like `file://`)
if let Some((scheme, _)) = split_scheme(path) {
return Scheme::parse(scheme).is_some();
}
// This is an ambiguous relative path
false
}
/// An error that can occur when parsing an [`IndexUrl`]. /// An error that can occur when parsing an [`IndexUrl`].
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum IndexUrlError { pub enum IndexUrlError {
@ -453,19 +411,6 @@ impl<'a> IndexLocations {
indexes indexes
} }
} }
/// Add all authenticated sources to the cache.
pub fn cache_index_credentials(&self) {
for index in self.allowed_indexes() {
if let Some(credentials) = index.credentials() {
let credentials = Arc::new(credentials);
uv_auth::store_credentials(index.raw_url(), credentials.clone());
if let Some(root_url) = index.root_url() {
uv_auth::store_credentials(&root_url, credentials.clone());
}
}
}
}
} }
impl From<&IndexLocations> for uv_auth::Indexes { impl From<&IndexLocations> for uv_auth::Indexes {
@ -680,41 +625,3 @@ impl IndexCapabilities {
.insert(Flags::FORBIDDEN); .insert(Flags::FORBIDDEN);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_index_url_parse_valid_paths() {
// Absolute path
assert!(is_disambiguated_path("/absolute/path"));
// Relative path
assert!(is_disambiguated_path("./relative/path"));
assert!(is_disambiguated_path("../../relative/path"));
if cfg!(windows) {
// Windows absolute path
assert!(is_disambiguated_path("C:/absolute/path"));
// Windows relative path
assert!(is_disambiguated_path(".\\relative\\path"));
assert!(is_disambiguated_path("..\\..\\relative\\path"));
}
}
#[test]
fn test_index_url_parse_ambiguous_paths() {
// Test single-segment ambiguous path
assert!(!is_disambiguated_path("index"));
// Test multi-segment ambiguous path
assert!(!is_disambiguated_path("relative/path"));
}
#[test]
fn test_index_url_parse_with_schemes() {
assert!(is_disambiguated_path("file:///absolute/path"));
assert!(is_disambiguated_path("https://registry.com/simple/"));
assert!(is_disambiguated_path(
"git+https://github.com/example/repo.git"
));
}
}

View file

@ -365,7 +365,7 @@ impl InstalledDist {
pub fn installer(&self) -> Result<Option<String>, InstalledDistError> { pub fn installer(&self) -> Result<Option<String>, InstalledDistError> {
let path = self.install_path().join("INSTALLER"); let path = self.install_path().join("INSTALLER");
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(installer) => Ok(Some(installer.trim().to_owned())), Ok(installer) => Ok(Some(installer)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()), Err(err) => Err(err.into()),
} }

View file

@ -3,8 +3,6 @@
//! flags set. //! flags set.
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::path::Path; use std::path::Path;
use crate::{Index, IndexUrl}; use crate::{Index, IndexUrl};
@ -52,14 +50,14 @@ macro_rules! impl_index {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for $name { impl schemars::JsonSchema for $name {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
IndexUrl::schema_name() IndexUrl::schema_name()
} }
fn json_schema( fn json_schema(
generator: &mut schemars::generate::SchemaGenerator, r#gen: &mut schemars::r#gen::SchemaGenerator,
) -> schemars::Schema { ) -> schemars::schema::Schema {
IndexUrl::json_schema(generator) IndexUrl::json_schema(r#gen)
} }
} }
}; };

View file

@ -66,8 +66,15 @@ impl RequiresPython {
) -> Option<Self> { ) -> Option<Self> {
// Convert to PubGrub range and perform an intersection. // Convert to PubGrub range and perform an intersection.
let range = specifiers let range = specifiers
.map(|specs| release_specifiers_to_ranges(specs.clone())) .into_iter()
.reduce(|acc, r| acc.intersection(&r))?; .map(|specifier| release_specifiers_to_ranges(specifier.clone()))
.fold(None, |range: Option<Ranges<Version>>, requires_python| {
if let Some(range) = range {
Some(range.intersection(&requires_python))
} else {
Some(requires_python)
}
})?;
// If the intersection is empty, return `None`. // If the intersection is empty, return `None`.
if range.is_empty() { if range.is_empty() {

View file

@ -1,5 +1,3 @@
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::ops::Deref; use std::ops::Deref;
use http::StatusCode; use http::StatusCode;
@ -138,17 +136,17 @@ impl<'de> Deserialize<'de> for SerializableStatusCode {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for SerializableStatusCode { impl schemars::JsonSchema for SerializableStatusCode {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("StatusCode") "StatusCode".to_string()
} }
fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(r#gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ let mut schema = r#gen.subschema_for::<u16>().into_object();
"type": "number", schema.metadata().description = Some("HTTP status code (100-599)".to_string());
"minimum": 100, schema.number().minimum = Some(100.0);
"maximum": 599, schema.number().maximum = Some(599.0);
"description": "HTTP status code (100-599)"
}) schema.into()
} }
} }

View file

@ -20,7 +20,8 @@ use uv_client::{
}; };
use uv_distribution_filename::WheelFilename; use uv_distribution_filename::WheelFilename;
use uv_distribution_types::{ use uv_distribution_types::{
BuildableSource, BuiltDist, Dist, HashPolicy, Hashed, InstalledDist, Name, SourceDist, BuildableSource, BuiltDist, Dist, FileLocation, HashPolicy, Hashed, InstalledDist, Name,
SourceDist,
}; };
use uv_extract::hash::Hasher; use uv_extract::hash::Hasher;
use uv_fs::write_atomic; use uv_fs::write_atomic;
@ -178,7 +179,12 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
match dist { match dist {
BuiltDist::Registry(wheels) => { BuiltDist::Registry(wheels) => {
let wheel = wheels.best_wheel(); let wheel = wheels.best_wheel();
let url = wheel.file.url.to_url()?; let url = match &wheel.file.url {
FileLocation::RelativeUrl(base, url) => {
uv_pypi_types::base_url_join_relative(base, url)?
}
FileLocation::AbsoluteUrl(url) => url.to_url()?,
};
// Create a cache entry for the wheel. // Create a cache entry for the wheel.
let wheel_entry = self.build_context.cache().entry( let wheel_entry = self.build_context.cache().entry(

View file

@ -25,6 +25,8 @@ pub enum Error {
RelativePath(PathBuf), RelativePath(PathBuf),
#[error(transparent)] #[error(transparent)]
InvalidUrl(#[from] uv_distribution_types::ToUrlError), InvalidUrl(#[from] uv_distribution_types::ToUrlError),
#[error(transparent)]
JoinRelativeUrl(#[from] uv_pypi_types::JoinRelativeError),
#[error("Expected a file URL, but received: {0}")] #[error("Expected a file URL, but received: {0}")]
NonFileUrl(DisplaySafeUrl), NonFileUrl(DisplaySafeUrl),
#[error(transparent)] #[error(transparent)]
@ -106,8 +108,6 @@ pub enum Error {
CacheHeal(String, HashAlgorithm), CacheHeal(String, HashAlgorithm),
#[error("The source distribution requires Python {0}, but {1} is installed")] #[error("The source distribution requires Python {0}, but {1} is installed")]
RequiresPython(VersionSpecifiers, Version), RequiresPython(VersionSpecifiers, Version),
#[error("Failed to identify base Python interpreter")]
BaseInterpreter(#[source] std::io::Error),
/// A generic request middleware error happened while making a request. /// A generic request middleware error happened while making a request.
/// Refer to the error message for more details. /// Refer to the error message for more details.

View file

@ -13,7 +13,7 @@ use uv_git_types::{GitReference, GitUrl, GitUrlParseError};
use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::VersionSpecifiers; use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository}; use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository};
use uv_pypi_types::{ConflictItem, ParsedGitUrl, ParsedUrlError, VerbatimParsedUrl}; use uv_pypi_types::{ConflictItem, ParsedUrlError, VerbatimParsedUrl};
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
use uv_workspace::Workspace; use uv_workspace::Workspace;
use uv_workspace::pyproject::{PyProjectToml, Source, Sources}; use uv_workspace::pyproject::{PyProjectToml, Source, Sources};
@ -700,23 +700,17 @@ fn path_source(
}; };
if is_dir { if is_dir {
if let Some(git_member) = git_member { if let Some(git_member) = git_member {
let git = git_member.git_source.git.clone();
let subdirectory = uv_fs::relative_to(install_path, git_member.fetch_root) let subdirectory = uv_fs::relative_to(install_path, git_member.fetch_root)
.expect("Workspace member must be relative"); .expect("Workspace member must be relative");
let subdirectory = uv_fs::normalize_path_buf(subdirectory); let subdirectory = uv_fs::normalize_path_buf(subdirectory);
let subdirectory = if subdirectory == PathBuf::new() {
None
} else {
Some(subdirectory.into_boxed_path())
};
let url = DisplaySafeUrl::from(ParsedGitUrl {
url: git.clone(),
subdirectory: subdirectory.clone(),
});
return Ok(RequirementSource::Git { return Ok(RequirementSource::Git {
git, git: git_member.git_source.git.clone(),
subdirectory, subdirectory: if subdirectory == PathBuf::new() {
url: VerbatimUrl::from_url(url), None
} else {
Some(subdirectory.into_boxed_path())
},
url,
}); });
} }

View file

@ -32,8 +32,8 @@ use uv_client::{
use uv_configuration::{BuildKind, BuildOutput, SourceStrategy}; use uv_configuration::{BuildKind, BuildOutput, SourceStrategy};
use uv_distribution_filename::{SourceDistExtension, WheelFilename}; use uv_distribution_filename::{SourceDistExtension, WheelFilename};
use uv_distribution_types::{ use uv_distribution_types::{
BuildableSource, DirectorySourceUrl, GitSourceUrl, HashPolicy, Hashed, PathSourceUrl, BuildableSource, DirectorySourceUrl, FileLocation, GitSourceUrl, HashPolicy, Hashed,
SourceDist, SourceUrl, PathSourceUrl, SourceDist, SourceUrl,
}; };
use uv_extract::hash::Hasher; use uv_extract::hash::Hasher;
use uv_fs::{rename_with_retry, write_atomic}; use uv_fs::{rename_with_retry, write_atomic};
@ -43,7 +43,7 @@ use uv_normalize::PackageName;
use uv_pep440::{Version, release_specifiers_to_ranges}; use uv_pep440::{Version, release_specifiers_to_ranges};
use uv_platform_tags::Tags; use uv_platform_tags::Tags;
use uv_pypi_types::{HashAlgorithm, HashDigest, HashDigests, PyProjectToml, ResolutionMetadata}; use uv_pypi_types::{HashAlgorithm, HashDigest, HashDigests, PyProjectToml, ResolutionMetadata};
use uv_types::{BuildContext, BuildKey, BuildStack, SourceBuildTrait}; use uv_types::{BuildContext, BuildStack, SourceBuildTrait};
use uv_workspace::pyproject::ToolUvSources; use uv_workspace::pyproject::ToolUvSources;
use crate::distribution_database::ManagedClient; use crate::distribution_database::ManagedClient;
@ -122,7 +122,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
.join(dist.version.to_string()), .join(dist.version.to_string()),
); );
let url = dist.file.url.to_url()?; let url = match &dist.file.url {
FileLocation::RelativeUrl(base, url) => {
uv_pypi_types::base_url_join_relative(base, url)?
}
FileLocation::AbsoluteUrl(url) => url.to_url()?,
};
// If the URL is a file URL, use the local path directly. // If the URL is a file URL, use the local path directly.
if url.scheme() == "file" { if url.scheme() == "file" {
@ -266,7 +271,12 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
.join(dist.version.to_string()), .join(dist.version.to_string()),
); );
let url = dist.file.url.to_url()?; let url = match &dist.file.url {
FileLocation::RelativeUrl(base, url) => {
uv_pypi_types::base_url_join_relative(base, url)?
}
FileLocation::AbsoluteUrl(url) => url.to_url()?,
};
// If the URL is a file URL, use the local path directly. // If the URL is a file URL, use the local path directly.
if url.scheme() == "file" { if url.scheme() == "file" {
@ -1850,12 +1860,6 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
} }
}; };
// If the URL is already precise, return it.
if self.build_context.git().get_precise(git).is_some() {
debug!("Precise commit already known: {source}");
return Ok(());
}
// If this is GitHub URL, attempt to resolve to a precise commit using the GitHub API. // If this is GitHub URL, attempt to resolve to a precise commit using the GitHub API.
if self if self
.build_context .build_context
@ -2266,7 +2270,6 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
fs::create_dir_all(&cache_shard) fs::create_dir_all(&cache_shard)
.await .await
.map_err(Error::CacheWrite)?; .map_err(Error::CacheWrite)?;
// Try a direct build if that isn't disabled and the uv build backend is used. // Try a direct build if that isn't disabled and the uv build backend is used.
let disk_filename = if let Some(name) = self let disk_filename = if let Some(name) = self
.build_context .build_context
@ -2287,73 +2290,27 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
// In the uv build backend, the normalized filename and the disk filename are the same. // In the uv build backend, the normalized filename and the disk filename are the same.
name.to_string() name.to_string()
} else { } else {
// Identify the base Python interpreter to use in the cache key. self.build_context
let base_python = if cfg!(unix) { .setup_build(
self.build_context source_root,
.interpreter() subdirectory,
.find_base_python() source_root,
.map_err(Error::BaseInterpreter)? Some(&source.to_string()),
} else { source.as_dist(),
self.build_context source_strategy,
.interpreter() if source.is_editable() {
.to_base_python() BuildKind::Editable
.map_err(Error::BaseInterpreter)? } else {
}; BuildKind::Wheel
},
let build_kind = if source.is_editable() { BuildOutput::Debug,
BuildKind::Editable self.build_stack.cloned().unwrap_or_default(),
} else { )
BuildKind::Wheel .await
}; .map_err(|err| Error::Build(err.into()))?
.wheel(temp_dir.path())
let build_key = BuildKey { .await
base_python: base_python.into_boxed_path(), .map_err(Error::Build)?
source_root: source_root.to_path_buf().into_boxed_path(),
subdirectory: subdirectory
.map(|subdirectory| subdirectory.to_path_buf().into_boxed_path()),
source_strategy,
build_kind,
};
if let Some(builder) = self.build_context.build_arena().remove(&build_key) {
debug!("Creating build environment for: {source}");
let wheel = builder.wheel(temp_dir.path()).await.map_err(Error::Build)?;
// Store the build context.
self.build_context.build_arena().insert(build_key, builder);
wheel
} else {
debug!("Reusing existing build environment for: {source}");
let builder = self
.build_context
.setup_build(
source_root,
subdirectory,
source_root,
Some(&source.to_string()),
source.as_dist(),
source_strategy,
if source.is_editable() {
BuildKind::Editable
} else {
BuildKind::Wheel
},
BuildOutput::Debug,
self.build_stack.cloned().unwrap_or_default(),
)
.await
.map_err(|err| Error::Build(err.into()))?;
// Build the wheel.
let wheel = builder.wheel(temp_dir.path()).await.map_err(Error::Build)?;
// Store the build context.
self.build_context.build_arena().insert(build_key, builder);
wheel
}
}; };
// Read the metadata from the wheel. // Read the metadata from the wheel.
@ -2408,26 +2365,6 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
} }
} }
// Identify the base Python interpreter to use in the cache key.
let base_python = if cfg!(unix) {
self.build_context
.interpreter()
.find_base_python()
.map_err(Error::BaseInterpreter)?
} else {
self.build_context
.interpreter()
.to_base_python()
.map_err(Error::BaseInterpreter)?
};
// Determine whether this is an editable or non-editable build.
let build_kind = if source.is_editable() {
BuildKind::Editable
} else {
BuildKind::Wheel
};
// Set up the builder. // Set up the builder.
let mut builder = self let mut builder = self
.build_context .build_context
@ -2438,7 +2375,11 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
Some(&source.to_string()), Some(&source.to_string()),
source.as_dist(), source.as_dist(),
source_strategy, source_strategy,
build_kind, if source.is_editable() {
BuildKind::Editable
} else {
BuildKind::Wheel
},
BuildOutput::Debug, BuildOutput::Debug,
self.build_stack.cloned().unwrap_or_default(), self.build_stack.cloned().unwrap_or_default(),
) )
@ -2447,21 +2388,6 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
// Build the metadata. // Build the metadata.
let dist_info = builder.metadata().await.map_err(Error::Build)?; let dist_info = builder.metadata().await.map_err(Error::Build)?;
// Store the build context.
self.build_context.build_arena().insert(
BuildKey {
base_python: base_python.into_boxed_path(),
source_root: source_root.to_path_buf().into_boxed_path(),
subdirectory: subdirectory
.map(|subdirectory| subdirectory.to_path_buf().into_boxed_path()),
source_strategy,
build_kind,
},
builder,
);
// Return the `.dist-info` directory, if it exists.
let Some(dist_info) = dist_info else { let Some(dist_info) = dist_info else {
return Ok(None); return Ok(None);
}; };

View file

@ -2,11 +2,11 @@ use std::{ffi::OsString, path::PathBuf};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Error { pub enum Error {
#[error("Failed to read from zip file")] #[error(transparent)]
Zip(#[from] zip::result::ZipError), Zip(#[from] zip::result::ZipError),
#[error("Failed to read from zip file")] #[error(transparent)]
AsyncZip(#[from] async_zip::error::ZipError), AsyncZip(#[from] async_zip::error::ZipError),
#[error("I/O operation failed during extraction")] #[error(transparent)]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
#[error( #[error(
"The top-level of the archive must only contain a list directory, but it contains: {0:?}" "The top-level of the archive must only contain a list directory, but it contains: {0:?}"

View file

@ -601,7 +601,6 @@ pub fn is_virtualenv_base(path: impl AsRef<Path>) -> bool {
/// A file lock that is automatically released when dropped. /// A file lock that is automatically released when dropped.
#[derive(Debug)] #[derive(Debug)]
#[must_use]
pub struct LockedFile(fs_err::File); pub struct LockedFile(fs_err::File);
impl LockedFile { impl LockedFile {

View file

@ -330,11 +330,11 @@ pub struct PortablePathBuf(Box<Path>);
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for PortablePathBuf { impl schemars::JsonSchema for PortablePathBuf {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("PortablePathBuf") PathBuf::schema_name()
} }
fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
PathBuf::json_schema(_gen) PathBuf::json_schema(_gen)
} }
} }

View file

@ -17,7 +17,7 @@ fn get_binary_type(path: &Path) -> windows::core::Result<u32> {
.chain(Some(0)) .chain(Some(0))
.collect::<Vec<u16>>(); .collect::<Vec<u16>>();
// SAFETY: winapi call // SAFETY: winapi call
unsafe { GetBinaryTypeW(PCWSTR(name.as_ptr()), &raw mut binary_type)? }; unsafe { GetBinaryTypeW(PCWSTR(name.as_ptr()), &mut binary_type)? };
Ok(binary_type) Ok(binary_type)
} }

View file

@ -20,8 +20,6 @@ use uv_redacted::DisplaySafeUrl;
use uv_static::EnvVars; use uv_static::EnvVars;
use uv_version::version; use uv_version::version;
use crate::rate_limit::{GITHUB_RATE_LIMIT_STATUS, is_github_rate_limited};
/// A file indicates that if present, `git reset` has been done and a repo /// A file indicates that if present, `git reset` has been done and a repo
/// checkout is ready to go. See [`GitCheckout::reset`] for why we need this. /// checkout is ready to go. See [`GitCheckout::reset`] for why we need this.
const CHECKOUT_READY_LOCK: &str = ".ok"; const CHECKOUT_READY_LOCK: &str = ".ok";
@ -789,15 +787,7 @@ fn github_fast_path(
} }
}; };
// Check if we're rate-limited by GitHub before determining the FastPathRev let url = format!("https://api.github.com/repos/{owner}/{repo}/commits/{github_branch_name}");
if GITHUB_RATE_LIMIT_STATUS.is_active() {
debug!("Skipping GitHub fast path attempt for: {url} (rate-limited)");
return Ok(FastPathRev::Indeterminate);
}
let base_url = std::env::var(EnvVars::UV_GITHUB_FAST_PATH_URL)
.unwrap_or("https://api.github.com/repos".to_owned());
let url = format!("{base_url}/{owner}/{repo}/commits/{github_branch_name}");
let runtime = tokio::runtime::Builder::new_current_thread() let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
@ -817,11 +807,6 @@ fn github_fast_path(
let response = request.send().await?; let response = request.send().await?;
if is_github_rate_limited(&response) {
// Mark that we are being rate-limited by GitHub
GITHUB_RATE_LIMIT_STATUS.activate();
}
// GitHub returns a 404 if the repository does not exist, and a 422 if it exists but GitHub // GitHub returns a 404 if the repository does not exist, and a 422 if it exists but GitHub
// is unable to resolve the requested revision. // is unable to resolve the requested revision.
response.error_for_status_ref()?; response.error_for_status_ref()?;

View file

@ -7,6 +7,5 @@ pub use crate::source::{Fetch, GitSource, Reporter};
mod credentials; mod credentials;
mod git; mod git;
mod rate_limit;
mod resolver; mod resolver;
mod source; mod source;

View file

@ -1,37 +0,0 @@
use reqwest::{Response, StatusCode};
use std::sync::atomic::{AtomicBool, Ordering};
/// A global state on whether we are being rate-limited by GitHub's REST API.
/// If we are, avoid "fast-path" attempts.
pub(crate) static GITHUB_RATE_LIMIT_STATUS: GitHubRateLimitStatus = GitHubRateLimitStatus::new();
/// GitHub REST API rate limit status tracker.
///
/// ## Assumptions
///
/// The rate limit timeout duration is much longer than the runtime of a `uv` command.
/// And so we do not need to invalidate this state based on `x-ratelimit-reset`.
#[derive(Debug)]
pub(crate) struct GitHubRateLimitStatus(AtomicBool);
impl GitHubRateLimitStatus {
const fn new() -> Self {
Self(AtomicBool::new(false))
}
pub(crate) fn activate(&self) {
self.0.store(true, Ordering::Relaxed);
}
pub(crate) fn is_active(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// Determine if GitHub is applying rate-limiting based on the response
pub(crate) fn is_github_rate_limited(response: &Response) -> bool {
// HTTP 403 and 429 are possible status codes in the event of a primary or secondary rate limit.
// Source: https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api?apiVersion=2022-11-28#rate-limit-errors
let status_code = response.status();
status_code == StatusCode::FORBIDDEN || status_code == StatusCode::TOO_MANY_REQUESTS
}

View file

@ -15,10 +15,7 @@ use uv_git_types::{GitHubRepository, GitOid, GitReference, GitUrl};
use uv_static::EnvVars; use uv_static::EnvVars;
use uv_version::version; use uv_version::version;
use crate::{ use crate::{Fetch, GitSource, Reporter};
Fetch, GitSource, Reporter,
rate_limit::{GITHUB_RATE_LIMIT_STATUS, is_github_rate_limited},
};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum GitResolverError { pub enum GitResolverError {
@ -49,21 +46,6 @@ impl GitResolver {
self.0.get(reference) self.0.get(reference)
} }
pub fn get_precise(&self, url: &GitUrl) -> Option<GitOid> {
// If the URL is already precise, return it.
if let Some(precise) = url.precise() {
return Some(precise);
}
// If we know the precise commit already, return it.
let reference = RepositoryReference::from(url);
if let Some(precise) = self.get(&reference) {
return Some(*precise);
}
None
}
/// Resolve a Git URL to a specific commit without performing any Git operations. /// Resolve a Git URL to a specific commit without performing any Git operations.
/// ///
/// Returns a [`GitOid`] if the URL has already been resolved (i.e., is available in the cache), /// Returns a [`GitOid`] if the URL has already been resolved (i.e., is available in the cache),
@ -77,32 +59,31 @@ impl GitResolver {
return Ok(None); return Ok(None);
} }
// If the URL is already precise or we know the precise commit, return it. let reference = RepositoryReference::from(url);
if let Some(precise) = self.get_precise(url) {
// If the URL is already precise, return it.
if let Some(precise) = url.precise() {
return Ok(Some(precise)); return Ok(Some(precise));
} }
// If we know the precise commit already, return it.
if let Some(precise) = self.get(&reference) {
return Ok(Some(*precise));
}
// If the URL is a GitHub URL, attempt to resolve it via the GitHub API. // If the URL is a GitHub URL, attempt to resolve it via the GitHub API.
let Some(GitHubRepository { owner, repo }) = GitHubRepository::parse(url.repository()) let Some(GitHubRepository { owner, repo }) = GitHubRepository::parse(url.repository())
else { else {
return Ok(None); return Ok(None);
}; };
// Check if we're rate-limited by GitHub, before determining the Git reference
if GITHUB_RATE_LIMIT_STATUS.is_active() {
debug!("Rate-limited by GitHub. Skipping GitHub fast path attempt for: {url}");
return Ok(None);
}
// Determine the Git reference. // Determine the Git reference.
let rev = url.reference().as_rev(); let rev = url.reference().as_rev();
let github_api_base_url = std::env::var(EnvVars::UV_GITHUB_FAST_PATH_URL) let url = format!("https://api.github.com/repos/{owner}/{repo}/commits/{rev}");
.unwrap_or("https://api.github.com/repos".to_owned());
let github_api_url = format!("{github_api_base_url}/{owner}/{repo}/commits/{rev}");
debug!("Querying GitHub for commit at: {github_api_url}"); debug!("Querying GitHub for commit at: {url}");
let mut request = client.get(&github_api_url); let mut request = client.get(&url);
request = request.header("Accept", "application/vnd.github.3.sha"); request = request.header("Accept", "application/vnd.github.3.sha");
request = request.header( request = request.header(
"User-Agent", "User-Agent",
@ -110,20 +91,13 @@ impl GitResolver {
); );
let response = request.send().await?; let response = request.send().await?;
let status = response.status(); if !response.status().is_success() {
if !status.is_success() {
// Returns a 404 if the repository does not exist, and a 422 if GitHub is unable to // Returns a 404 if the repository does not exist, and a 422 if GitHub is unable to
// resolve the requested rev. // resolve the requested rev.
debug!( debug!(
"GitHub API request failed for: {github_api_url} ({})", "GitHub API request failed for: {url} ({})",
response.status() response.status()
); );
if is_github_rate_limited(&response) {
// Mark that we are being rate-limited by GitHub
GITHUB_RATE_LIMIT_STATUS.activate();
}
return Ok(None); return Ok(None);
} }
@ -134,7 +108,7 @@ impl GitResolver {
// Insert the resolved URL into the in-memory cache. This ensures that subsequent fetches // Insert the resolved URL into the in-memory cache. This ensures that subsequent fetches
// resolve to the same precise commit. // resolve to the same precise commit.
self.insert(RepositoryReference::from(url), precise); self.insert(reference, precise);
Ok(Some(precise)) Ok(Some(precise))
} }

View file

@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::io; use std::io;
use std::io::{BufReader, Read, Write}; use std::io::{BufReader, Read, Seek, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use data_encoding::BASE64URL_NOPAD; use data_encoding::BASE64URL_NOPAD;
@ -144,7 +144,7 @@ fn format_shebang(executable: impl AsRef<Path>, os_name: &str, relocatable: bool
/// ///
/// <https://github.com/pypa/pip/blob/76e82a43f8fb04695e834810df64f2d9a2ff6020/src/pip/_vendor/distlib/scripts.py#L121-L126> /// <https://github.com/pypa/pip/blob/76e82a43f8fb04695e834810df64f2d9a2ff6020/src/pip/_vendor/distlib/scripts.py#L121-L126>
fn get_script_executable(python_executable: &Path, is_gui: bool) -> PathBuf { fn get_script_executable(python_executable: &Path, is_gui: bool) -> PathBuf {
// Only check for `pythonw.exe` on Windows. // Only check for pythonw.exe on Windows
if cfg!(windows) && is_gui { if cfg!(windows) && is_gui {
python_executable python_executable
.file_name() .file_name()
@ -431,41 +431,22 @@ fn install_script(
Err(err) => return Err(Error::Io(err)), Err(err) => return Err(Error::Io(err)),
} }
let size_and_encoded_hash = if start == placeholder_python { let size_and_encoded_hash = if start == placeholder_python {
// Read the rest of the first line, one byte at a time, until we hit a newline. let is_gui = {
let mut is_gui = false; let mut buf = vec![0; 1];
let mut first = true; script.read_exact(&mut buf)?;
let mut byte = [0u8; 1]; if buf == b"w" {
loop { true
match script.read_exact(&mut byte) { } else {
Ok(()) => { script.seek_relative(-1)?;
if byte[0] == b'\n' || byte[0] == b'\r' { false
break;
}
// Check if this is a GUI script (starts with 'w').
if first {
is_gui = byte[0] == b'w';
first = false;
}
}
Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => break,
Err(err) => return Err(Error::Io(err)),
} }
} };
let executable = get_script_executable(&layout.sys_executable, is_gui); let executable = get_script_executable(&layout.sys_executable, is_gui);
let executable = get_relocatable_executable(executable, layout, relocatable)?; let executable = get_relocatable_executable(executable, layout, relocatable)?;
let mut start = format_shebang(&executable, &layout.os_name, relocatable) let start = format_shebang(&executable, &layout.os_name, relocatable)
.as_bytes() .as_bytes()
.to_vec(); .to_vec();
// Use appropriate line ending for the platform.
if layout.os_name == "nt" {
start.extend_from_slice(b"\r\n");
} else {
start.push(b'\n');
}
let mut target = uv_fs::tempfile_in(&layout.scheme.scripts)?; let mut target = uv_fs::tempfile_in(&layout.scheme.scripts)?;
let size_and_encoded_hash = copy_and_hash(&mut start.chain(script), &mut target)?; let size_and_encoded_hash = copy_and_hash(&mut start.chain(script), &mut target)?;

View file

@ -29,12 +29,12 @@ pub use version_ranges::{
}; };
pub use { pub use {
version::{ version::{
BumpCommand, LocalSegment, LocalVersion, LocalVersionSlice, MIN_VERSION, Operator, LocalSegment, LocalVersion, LocalVersionSlice, MIN_VERSION, Operator, OperatorParseError,
OperatorParseError, Prerelease, PrereleaseKind, Version, VersionParseError, VersionPattern, Prerelease, PrereleaseKind, Version, VersionParseError, VersionPattern,
VersionPatternParseError, VersionPatternParseError,
}, },
version_specifier::{ version_specifier::{
TildeVersionSpecifier, VersionSpecifier, VersionSpecifierBuildError, VersionSpecifiers, VersionSpecifier, VersionSpecifierBuildError, VersionSpecifiers,
VersionSpecifiersParseError, VersionSpecifiersParseError,
}, },
}; };

View file

@ -610,24 +610,6 @@ impl Version {
Self::new(self.release().iter().copied()) Self::new(self.release().iter().copied())
} }
/// Return the version with any segments apart from the release removed, with trailing zeroes
/// trimmed.
#[inline]
#[must_use]
pub fn only_release_trimmed(&self) -> Self {
if let Some(last_non_zero) = self.release().iter().rposition(|segment| *segment != 0) {
if last_non_zero == self.release().len() {
// Already trimmed.
self.clone()
} else {
Self::new(self.release().iter().take(last_non_zero + 1).copied())
}
} else {
// `0` is a valid version.
Self::new([0])
}
}
/// Return the version with trailing `.0` release segments removed. /// Return the version with trailing `.0` release segments removed.
/// ///
/// # Panics /// # Panics
@ -643,90 +625,6 @@ impl Version {
self.with_release(release) self.with_release(release)
} }
/// Various "increment the version" operations
pub fn bump(&mut self, bump: BumpCommand) {
// This code operates on the understanding that the components of a version form
// the following hierarchy:
//
// major > minor > patch > stable > pre > post > dev
//
// Any updates to something earlier in the hierarchy should clear all values lower
// in the hierarchy. So for instance:
//
// if you bump `minor`, then clear: patch, pre, post, dev
// if you bump `pre`, then clear: post, dev
//
// ...and so on.
//
// If you bump a value that doesn't exist, it will be set to "1".
//
// The special "stable" mode has no value, bumping it clears: pre, post, dev.
let full = self.make_full();
match bump {
BumpCommand::BumpRelease { index } => {
// Clear all sub-release items
full.pre = None;
full.post = None;
full.dev = None;
// Use `max` here to try to do 0.2 => 0.3 instead of 0.2 => 0.3.0
let old_parts = &full.release;
let len = old_parts.len().max(index + 1);
let new_release_vec = (0..len)
.map(|i| match i.cmp(&index) {
// Everything before the bumped value is preserved (or is an implicit 0)
Ordering::Less => old_parts.get(i).copied().unwrap_or(0),
// This is the value to bump (could be implicit 0)
Ordering::Equal => old_parts.get(i).copied().unwrap_or(0) + 1,
// Everything after the bumped value becomes 0
Ordering::Greater => 0,
})
.collect::<Vec<u64>>();
full.release = new_release_vec;
}
BumpCommand::MakeStable => {
// Clear all sub-release items
full.pre = None;
full.post = None;
full.dev = None;
}
BumpCommand::BumpPrerelease { kind } => {
// Clear all sub-prerelease items
full.post = None;
full.dev = None;
// Either bump the matching kind or set to 1
if let Some(prerelease) = &mut full.pre {
if prerelease.kind == kind {
prerelease.number += 1;
return;
}
}
full.pre = Some(Prerelease { kind, number: 1 });
}
BumpCommand::BumpPost => {
// Clear sub-post items
full.dev = None;
// Either bump or set to 1
if let Some(post) = &mut full.post {
*post += 1;
} else {
full.post = Some(1);
}
}
BumpCommand::BumpDev => {
// Either bump or set to 1
if let Some(dev) = &mut full.dev {
*dev += 1;
} else {
full.dev = Some(1);
}
}
}
}
/// Set the min-release component and return the updated version. /// Set the min-release component and return the updated version.
/// ///
/// The "min" component is internal-only, and does not exist in PEP 440. /// The "min" component is internal-only, and does not exist in PEP 440.
@ -963,27 +861,6 @@ impl FromStr for Version {
} }
} }
/// Various ways to "bump" a version
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum BumpCommand {
/// Bump the release component
BumpRelease {
/// The release component to bump (0 is major, 1 is minor, 2 is patch)
index: usize,
},
/// Bump the prerelease component
BumpPrerelease {
/// prerelease component to bump
kind: PrereleaseKind,
},
/// Bump to the associated stable release
MakeStable,
/// Bump the post component
BumpPost,
/// Bump the dev component
BumpDev,
}
/// A small representation of a version. /// A small representation of a version.
/// ///
/// This representation is used for a (very common) subset of versions: the /// This representation is used for a (very common) subset of versions: the
@ -4148,351 +4025,4 @@ mod tests {
assert_eq!(size_of::<VersionSmall>(), size_of::<usize>() * 2); assert_eq!(size_of::<VersionSmall>(), size_of::<usize>() * 2);
assert_eq!(size_of::<Version>(), size_of::<usize>() * 2); assert_eq!(size_of::<Version>(), size_of::<usize>() * 2);
} }
/// Test major bumping
/// Explicitly using the string display because we want to preserve formatting where possible!
#[test]
fn bump_major() {
// one digit
let mut version = "0".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 0 });
assert_eq!(version.to_string().as_str(), "1");
// two digit
let mut version = "1.5".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 0 });
assert_eq!(version.to_string().as_str(), "2.0");
// three digit (zero major)
let mut version = "0.1.2".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 0 });
assert_eq!(version.to_string().as_str(), "1.0.0");
// three digit (non-zero major)
let mut version = "1.2.3".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 0 });
assert_eq!(version.to_string().as_str(), "2.0.0");
// four digit
let mut version = "1.2.3.4".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 0 });
assert_eq!(version.to_string().as_str(), "2.0.0.0");
// All the version junk
let mut version = "5!1.7.3.5b2.post345.dev456+local"
.parse::<Version>()
.unwrap();
version.bump(BumpCommand::BumpRelease { index: 0 });
assert_eq!(version.to_string().as_str(), "5!2.0.0.0+local");
version.bump(BumpCommand::BumpRelease { index: 0 });
assert_eq!(version.to_string().as_str(), "5!3.0.0.0+local");
}
/// Test minor bumping
/// Explicitly using the string display because we want to preserve formatting where possible!
#[test]
fn bump_minor() {
// one digit
let mut version = "0".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 1 });
assert_eq!(version.to_string().as_str(), "0.1");
// two digit
let mut version = "1.5".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 1 });
assert_eq!(version.to_string().as_str(), "1.6");
// three digit (non-zero major)
let mut version = "5.3.6".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 1 });
assert_eq!(version.to_string().as_str(), "5.4.0");
// four digit
let mut version = "1.2.3.4".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 1 });
assert_eq!(version.to_string().as_str(), "1.3.0.0");
// All the version junk
let mut version = "5!1.7.3.5b2.post345.dev456+local"
.parse::<Version>()
.unwrap();
version.bump(BumpCommand::BumpRelease { index: 1 });
assert_eq!(version.to_string().as_str(), "5!1.8.0.0+local");
version.bump(BumpCommand::BumpRelease { index: 1 });
assert_eq!(version.to_string().as_str(), "5!1.9.0.0+local");
}
/// Test patch bumping
/// Explicitly using the string display because we want to preserve formatting where possible!
#[test]
fn bump_patch() {
// one digit
let mut version = "0".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 2 });
assert_eq!(version.to_string().as_str(), "0.0.1");
// two digit
let mut version = "1.5".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 2 });
assert_eq!(version.to_string().as_str(), "1.5.1");
// three digit
let mut version = "5.3.6".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 2 });
assert_eq!(version.to_string().as_str(), "5.3.7");
// four digit
let mut version = "1.2.3.4".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpRelease { index: 2 });
assert_eq!(version.to_string().as_str(), "1.2.4.0");
// All the version junk
let mut version = "5!1.7.3.5b2.post345.dev456+local"
.parse::<Version>()
.unwrap();
version.bump(BumpCommand::BumpRelease { index: 2 });
assert_eq!(version.to_string().as_str(), "5!1.7.4.0+local");
version.bump(BumpCommand::BumpRelease { index: 2 });
assert_eq!(version.to_string().as_str(), "5!1.7.5.0+local");
}
/// Test alpha bumping
/// Explicitly using the string display because we want to preserve formatting where possible!
#[test]
fn bump_alpha() {
// one digit
let mut version = "0".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Alpha,
});
assert_eq!(version.to_string().as_str(), "0a1");
// two digit
let mut version = "1.5".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Alpha,
});
assert_eq!(version.to_string().as_str(), "1.5a1");
// three digit
let mut version = "5.3.6".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Alpha,
});
assert_eq!(version.to_string().as_str(), "5.3.6a1");
// four digit
let mut version = "1.2.3.4".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Alpha,
});
assert_eq!(version.to_string().as_str(), "1.2.3.4a1");
// All the version junk
let mut version = "5!1.7.3.5b2.post345.dev456+local"
.parse::<Version>()
.unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Alpha,
});
assert_eq!(version.to_string().as_str(), "5!1.7.3.5a1+local");
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Alpha,
});
assert_eq!(version.to_string().as_str(), "5!1.7.3.5a2+local");
}
/// Test beta bumping
/// Explicitly using the string display because we want to preserve formatting where possible!
#[test]
fn bump_beta() {
// one digit
let mut version = "0".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Beta,
});
assert_eq!(version.to_string().as_str(), "0b1");
// two digit
let mut version = "1.5".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Beta,
});
assert_eq!(version.to_string().as_str(), "1.5b1");
// three digit
let mut version = "5.3.6".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Beta,
});
assert_eq!(version.to_string().as_str(), "5.3.6b1");
// four digit
let mut version = "1.2.3.4".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Beta,
});
assert_eq!(version.to_string().as_str(), "1.2.3.4b1");
// All the version junk
let mut version = "5!1.7.3.5a2.post345.dev456+local"
.parse::<Version>()
.unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Beta,
});
assert_eq!(version.to_string().as_str(), "5!1.7.3.5b1+local");
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Beta,
});
assert_eq!(version.to_string().as_str(), "5!1.7.3.5b2+local");
}
/// Test rc bumping
/// Explicitly using the string display because we want to preserve formatting where possible!
#[test]
fn bump_rc() {
// one digit
let mut version = "0".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Rc,
});
assert_eq!(version.to_string().as_str(), "0rc1");
// two digit
let mut version = "1.5".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Rc,
});
assert_eq!(version.to_string().as_str(), "1.5rc1");
// three digit
let mut version = "5.3.6".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Rc,
});
assert_eq!(version.to_string().as_str(), "5.3.6rc1");
// four digit
let mut version = "1.2.3.4".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Rc,
});
assert_eq!(version.to_string().as_str(), "1.2.3.4rc1");
// All the version junk
let mut version = "5!1.7.3.5b2.post345.dev456+local"
.parse::<Version>()
.unwrap();
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Rc,
});
assert_eq!(version.to_string().as_str(), "5!1.7.3.5rc1+local");
version.bump(BumpCommand::BumpPrerelease {
kind: PrereleaseKind::Rc,
});
assert_eq!(version.to_string().as_str(), "5!1.7.3.5rc2+local");
}
/// Test post bumping
/// Explicitly using the string display because we want to preserve formatting where possible!
#[test]
fn bump_post() {
// one digit
let mut version = "0".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPost);
assert_eq!(version.to_string().as_str(), "0.post1");
// two digit
let mut version = "1.5".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPost);
assert_eq!(version.to_string().as_str(), "1.5.post1");
// three digit
let mut version = "5.3.6".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPost);
assert_eq!(version.to_string().as_str(), "5.3.6.post1");
// four digit
let mut version = "1.2.3.4".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPost);
assert_eq!(version.to_string().as_str(), "1.2.3.4.post1");
// All the version junk
let mut version = "5!1.7.3.5b2.dev123+local".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpPost);
assert_eq!(version.to_string().as_str(), "5!1.7.3.5b2.post1+local");
version.bump(BumpCommand::BumpPost);
assert_eq!(version.to_string().as_str(), "5!1.7.3.5b2.post2+local");
}
/// Test dev bumping
/// Explicitly using the string display because we want to preserve formatting where possible!
#[test]
fn bump_dev() {
// one digit
let mut version = "0".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpDev);
assert_eq!(version.to_string().as_str(), "0.dev1");
// two digit
let mut version = "1.5".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpDev);
assert_eq!(version.to_string().as_str(), "1.5.dev1");
// three digit
let mut version = "5.3.6".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpDev);
assert_eq!(version.to_string().as_str(), "5.3.6.dev1");
// four digit
let mut version = "1.2.3.4".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpDev);
assert_eq!(version.to_string().as_str(), "1.2.3.4.dev1");
// All the version junk
let mut version = "5!1.7.3.5b2.post345+local".parse::<Version>().unwrap();
version.bump(BumpCommand::BumpDev);
assert_eq!(
version.to_string().as_str(),
"5!1.7.3.5b2.post345.dev1+local"
);
version.bump(BumpCommand::BumpDev);
assert_eq!(
version.to_string().as_str(),
"5!1.7.3.5b2.post345.dev2+local"
);
}
/// Test stable setting
/// Explicitly using the string display because we want to preserve formatting where possible!
#[test]
fn make_stable() {
// one digit
let mut version = "0".parse::<Version>().unwrap();
version.bump(BumpCommand::MakeStable);
assert_eq!(version.to_string().as_str(), "0");
// two digit
let mut version = "1.5".parse::<Version>().unwrap();
version.bump(BumpCommand::MakeStable);
assert_eq!(version.to_string().as_str(), "1.5");
// three digit
let mut version = "5.3.6".parse::<Version>().unwrap();
version.bump(BumpCommand::MakeStable);
assert_eq!(version.to_string().as_str(), "5.3.6");
// four digit
let mut version = "1.2.3.4".parse::<Version>().unwrap();
version.bump(BumpCommand::MakeStable);
assert_eq!(version.to_string().as_str(), "1.2.3.4");
// All the version junk
let mut version = "5!1.7.3.5b2.post345+local".parse::<Version>().unwrap();
version.bump(BumpCommand::MakeStable);
assert_eq!(version.to_string().as_str(), "5!1.7.3.5+local");
version.bump(BumpCommand::MakeStable);
assert_eq!(version.to_string().as_str(), "5!1.7.3.5+local");
}
} }

View file

@ -132,7 +132,7 @@ impl From<VersionSpecifier> for Ranges<Version> {
pub fn release_specifiers_to_ranges(specifiers: VersionSpecifiers) -> Ranges<Version> { pub fn release_specifiers_to_ranges(specifiers: VersionSpecifiers) -> Ranges<Version> {
let mut range = Ranges::full(); let mut range = Ranges::full();
for specifier in specifiers { for specifier in specifiers {
range = range.intersection(&release_specifier_to_range(specifier, false)); range = range.intersection(&release_specifier_to_range(specifier));
} }
range range
} }
@ -148,57 +148,67 @@ pub fn release_specifiers_to_ranges(specifiers: VersionSpecifiers) -> Ranges<Ver
/// is allowed for projects that declare `requires-python = ">3.13"`. /// is allowed for projects that declare `requires-python = ">3.13"`.
/// ///
/// See: <https://github.com/pypa/pip/blob/a432c7f4170b9ef798a15f035f5dfdb4cc939f35/src/pip/_internal/resolution/resolvelib/candidates.py#L540> /// See: <https://github.com/pypa/pip/blob/a432c7f4170b9ef798a15f035f5dfdb4cc939f35/src/pip/_internal/resolution/resolvelib/candidates.py#L540>
pub fn release_specifier_to_range(specifier: VersionSpecifier, trim: bool) -> Ranges<Version> { pub fn release_specifier_to_range(specifier: VersionSpecifier) -> Ranges<Version> {
let VersionSpecifier { operator, version } = specifier; let VersionSpecifier { operator, version } = specifier;
// Note(konsti): We switched strategies to trimmed for the markers, but we don't want to cause
// churn in lockfile requires-python, so we only trim for markers.
let version_trimmed = if trim {
version.only_release_trimmed()
} else {
version.only_release()
};
match operator { match operator {
// Trailing zeroes are not semantically relevant. Operator::Equal => {
Operator::Equal => Ranges::singleton(version_trimmed), let version = version.only_release();
Operator::ExactEqual => Ranges::singleton(version_trimmed), Ranges::singleton(version)
Operator::NotEqual => Ranges::singleton(version_trimmed).complement(), }
Operator::LessThan => Ranges::strictly_lower_than(version_trimmed), Operator::ExactEqual => {
Operator::LessThanEqual => Ranges::lower_than(version_trimmed), let version = version.only_release();
Operator::GreaterThan => Ranges::strictly_higher_than(version_trimmed), Ranges::singleton(version)
Operator::GreaterThanEqual => Ranges::higher_than(version_trimmed), }
Operator::NotEqual => {
// Trailing zeroes are semantically relevant. let version = version.only_release();
Ranges::singleton(version).complement()
}
Operator::TildeEqual => { Operator::TildeEqual => {
let release = version.release(); let release = version.release();
let [rest @ .., last, _] = &*release else { let [rest @ .., last, _] = &*release else {
unreachable!("~= must have at least two segments"); unreachable!("~= must have at least two segments");
}; };
let upper = Version::new(rest.iter().chain([&(last + 1)])); let upper = Version::new(rest.iter().chain([&(last + 1)]));
Ranges::from_range_bounds(version_trimmed..upper) let version = version.only_release();
Ranges::from_range_bounds(version..upper)
}
Operator::LessThan => {
let version = version.only_release();
Ranges::strictly_lower_than(version)
}
Operator::LessThanEqual => {
let version = version.only_release();
Ranges::lower_than(version)
}
Operator::GreaterThan => {
let version = version.only_release();
Ranges::strictly_higher_than(version)
}
Operator::GreaterThanEqual => {
let version = version.only_release();
Ranges::higher_than(version)
} }
Operator::EqualStar => { Operator::EqualStar => {
// For (not-)equal-star, trailing zeroes are still before the star. let low = version.only_release();
let low_full = version.only_release();
let high = { let high = {
let mut high = low_full.clone(); let mut high = low.clone();
let mut release = high.release().to_vec(); let mut release = high.release().to_vec();
*release.last_mut().unwrap() += 1; *release.last_mut().unwrap() += 1;
high = high.with_release(release); high = high.with_release(release);
high high
}; };
Ranges::from_range_bounds(version..high) Ranges::from_range_bounds(low..high)
} }
Operator::NotEqualStar => { Operator::NotEqualStar => {
// For (not-)equal-star, trailing zeroes are still before the star. let low = version.only_release();
let low_full = version.only_release();
let high = { let high = {
let mut high = low_full.clone(); let mut high = low.clone();
let mut release = high.release().to_vec(); let mut release = high.release().to_vec();
*release.last_mut().unwrap() += 1; *release.last_mut().unwrap() += 1;
high = high.with_release(release); high = high.with_release(release);
high high
}; };
Ranges::from_range_bounds(version..high).complement() Ranges::from_range_bounds(low..high).complement()
} }
} }
} }
@ -213,8 +223,8 @@ impl LowerBound {
/// These bounds use release-only semantics when comparing versions. /// These bounds use release-only semantics when comparing versions.
pub fn new(bound: Bound<Version>) -> Self { pub fn new(bound: Bound<Version>) -> Self {
Self(match bound { Self(match bound {
Bound::Included(version) => Bound::Included(version.only_release_trimmed()), Bound::Included(version) => Bound::Included(version.only_release()),
Bound::Excluded(version) => Bound::Excluded(version.only_release_trimmed()), Bound::Excluded(version) => Bound::Excluded(version.only_release()),
Bound::Unbounded => Bound::Unbounded, Bound::Unbounded => Bound::Unbounded,
}) })
} }
@ -348,8 +358,8 @@ impl UpperBound {
/// These bounds use release-only semantics when comparing versions. /// These bounds use release-only semantics when comparing versions.
pub fn new(bound: Bound<Version>) -> Self { pub fn new(bound: Bound<Version>) -> Self {
Self(match bound { Self(match bound {
Bound::Included(version) => Bound::Included(version.only_release_trimmed()), Bound::Included(version) => Bound::Included(version.only_release()),
Bound::Excluded(version) => Bound::Excluded(version.only_release_trimmed()), Bound::Excluded(version) => Bound::Excluded(version.only_release()),
Bound::Unbounded => Bound::Unbounded, Bound::Unbounded => Bound::Unbounded,
}) })
} }

View file

@ -80,38 +80,24 @@ impl VersionSpecifiers {
// Add specifiers for the holes between the bounds. // Add specifiers for the holes between the bounds.
for (lower, upper) in bounds { for (lower, upper) in bounds {
let specifier = match (next, lower) { match (next, lower) {
// Ex) [3.7, 3.8.5), (3.8.5, 3.9] -> >=3.7,!=3.8.5,<=3.9 // Ex) [3.7, 3.8.5), (3.8.5, 3.9] -> >=3.7,!=3.8.5,<=3.9
(Bound::Excluded(prev), Bound::Excluded(lower)) if prev == lower => { (Bound::Excluded(prev), Bound::Excluded(lower)) if prev == lower => {
Some(VersionSpecifier::not_equals_version(prev.clone())) specifiers.push(VersionSpecifier::not_equals_version(prev.clone()));
} }
// Ex) [3.7, 3.8), (3.8, 3.9] -> >=3.7,!=3.8.*,<=3.9 // Ex) [3.7, 3.8), (3.8, 3.9] -> >=3.7,!=3.8.*,<=3.9
(Bound::Excluded(prev), Bound::Included(lower)) => { (Bound::Excluded(prev), Bound::Included(lower))
match *prev.only_release_trimmed().release() { if prev.release().len() == 2
[major] if *lower.only_release_trimmed().release() == [major, 1] => { && *lower.release() == [prev.release()[0], prev.release()[1] + 1] =>
Some(VersionSpecifier::not_equals_star_version(Version::new([ {
major, 0, specifiers.push(VersionSpecifier::not_equals_star_version(prev.clone()));
]))) }
} _ => {
[major, minor] #[cfg(feature = "tracing")]
if *lower.only_release_trimmed().release() == [major, minor + 1] => warn!(
{ "Ignoring unsupported gap in `requires-python` version: {next:?} -> {lower:?}"
Some(VersionSpecifier::not_equals_star_version(Version::new([ );
major, minor,
])))
}
_ => None,
}
} }
_ => None,
};
if let Some(specifier) = specifier {
specifiers.push(specifier);
} else {
#[cfg(feature = "tracing")]
warn!(
"Ignoring unsupported gap in `requires-python` version: {next:?} -> {lower:?}"
);
} }
next = upper; next = upper;
} }
@ -362,33 +348,6 @@ impl VersionSpecifier {
Ok(Self { operator, version }) Ok(Self { operator, version })
} }
/// Remove all non-release parts of the version.
///
/// The marker decision diagram relies on the assumption that the negation of a marker tree is
/// the complement of the marker space. However, pre-release versions violate this assumption.
///
/// For example, the marker `python_full_version > '3.9' or python_full_version <= '3.9'`
/// does not match `python_full_version == 3.9.0a0` and so cannot simplify to `true`. However,
/// its negation, `python_full_version > '3.9' and python_full_version <= '3.9'`, also does not
/// match `3.9.0a0` and simplifies to `false`, which violates the algebra decision diagrams
/// rely on. For this reason we ignore pre-release versions entirely when evaluating markers.
///
/// Note that `python_version` cannot take on pre-release values as it is truncated to just the
/// major and minor version segments. Thus using release-only specifiers is definitely necessary
/// for `python_version` to fully simplify any ranges, such as
/// `python_version > '3.9' or python_version <= '3.9'`, which is always `true` for
/// `python_version`. For `python_full_version` however, this decision is a semantic change.
///
/// For Python versions, the major.minor is considered the API version, so unlike the rules
/// for package versions in PEP 440, we Python `3.9.0a0` is acceptable for `>= "3.9"`.
#[must_use]
pub fn only_release(self) -> Self {
Self {
operator: self.operator,
version: self.version.only_release(),
}
}
/// `==<version>` /// `==<version>`
pub fn equals_version(version: Version) -> Self { pub fn equals_version(version: Version) -> Self {
Self { Self {
@ -457,7 +416,7 @@ impl VersionSpecifier {
&self.operator &self.operator
} }
/// Get the version, e.g. `2.0.0` in `<= 2.0.0` /// Get the version, e.g. `<=` in `<= 2.0.0`
pub fn version(&self) -> &Version { pub fn version(&self) -> &Version {
&self.version &self.version
} }
@ -483,23 +442,14 @@ impl VersionSpecifier {
(Some(VersionSpecifier::equals_version(v1.clone())), None) (Some(VersionSpecifier::equals_version(v1.clone())), None)
} }
// `v >= 3.7 && v < 3.8` is equivalent to `v == 3.7.*` // `v >= 3.7 && v < 3.8` is equivalent to `v == 3.7.*`
(Bound::Included(v1), Bound::Excluded(v2)) => { (Bound::Included(v1), Bound::Excluded(v2))
match *v1.only_release_trimmed().release() { if v1.release().len() == 2
[major] if *v2.only_release_trimmed().release() == [major, 1] => { && *v2.release() == [v1.release()[0], v1.release()[1] + 1] =>
let version = Version::new([major, 0]); {
(Some(VersionSpecifier::equals_star_version(version)), None) (
} Some(VersionSpecifier::equals_star_version(v1.clone())),
[major, minor] None,
if *v2.only_release_trimmed().release() == [major, minor + 1] => )
{
let version = Version::new([major, minor]);
(Some(VersionSpecifier::equals_star_version(version)), None)
}
_ => (
VersionSpecifier::from_lower_bound(&Bound::Included(v1.clone())),
VersionSpecifier::from_upper_bound(&Bound::Excluded(v2.clone())),
),
}
} }
(lower, upper) => ( (lower, upper) => (
VersionSpecifier::from_lower_bound(lower), VersionSpecifier::from_lower_bound(lower),
@ -888,90 +838,6 @@ pub(crate) fn parse_version_specifiers(
Ok(version_ranges) Ok(version_ranges)
} }
/// A simple `~=` version specifier with a major, minor and (optional) patch version, e.g., `~=3.13`
/// or `~=3.13.0`.
#[derive(Clone, Debug)]
pub struct TildeVersionSpecifier<'a> {
inner: Cow<'a, VersionSpecifier>,
}
impl<'a> TildeVersionSpecifier<'a> {
/// Create a new [`TildeVersionSpecifier`] from a [`VersionSpecifier`] value.
///
/// If a [`Operator::TildeEqual`] is not used, or the version includes more than minor and patch
/// segments, this will return [`None`].
pub fn from_specifier(specifier: VersionSpecifier) -> Option<TildeVersionSpecifier<'a>> {
TildeVersionSpecifier::new(Cow::Owned(specifier))
}
/// Create a new [`TildeVersionSpecifier`] from a [`VersionSpecifier`] reference.
///
/// See [`TildeVersionSpecifier::from_specifier`].
pub fn from_specifier_ref(
specifier: &'a VersionSpecifier,
) -> Option<TildeVersionSpecifier<'a>> {
TildeVersionSpecifier::new(Cow::Borrowed(specifier))
}
fn new(specifier: Cow<'a, VersionSpecifier>) -> Option<Self> {
if specifier.operator != Operator::TildeEqual {
return None;
}
if specifier.version().release().len() < 2 || specifier.version().release().len() > 3 {
return None;
}
if specifier.version().any_prerelease()
|| specifier.version().is_local()
|| specifier.version().is_post()
{
return None;
}
Some(Self { inner: specifier })
}
/// Whether a patch version is present in this tilde version specifier.
pub fn has_patch(&self) -> bool {
self.inner.version.release().len() == 3
}
/// Construct the lower and upper bounding version specifiers for this tilde version specifier,
/// e.g., for `~=3.13` this would return `>=3.13` and `<4` and for `~=3.13.0` it would
/// return `>=3.13.0` and `<3.14`.
pub fn bounding_specifiers(&self) -> (VersionSpecifier, VersionSpecifier) {
let release = self.inner.version().release();
let lower = self.inner.version.clone();
let upper = if self.has_patch() {
Version::new([release[0], release[1] + 1])
} else {
Version::new([release[0] + 1])
};
(
VersionSpecifier::greater_than_equal_version(lower),
VersionSpecifier::less_than_version(upper),
)
}
/// Construct a new tilde `VersionSpecifier` with the given patch version appended.
pub fn with_patch_version(&self, patch: u64) -> TildeVersionSpecifier {
let mut release = self.inner.version.release().to_vec();
if self.has_patch() {
release.pop();
}
release.push(patch);
TildeVersionSpecifier::from_specifier(
VersionSpecifier::from_version(Operator::TildeEqual, Version::new(release))
.expect("We should always derive a valid new version specifier"),
)
.expect("We should always derive a new tilde version specifier")
}
}
impl std::fmt::Display for TildeVersionSpecifier<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.inner)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::{cmp::Ordering, str::FromStr}; use std::{cmp::Ordering, str::FromStr};

View file

@ -41,7 +41,7 @@ version-ranges = { workspace = true }
[dev-dependencies] [dev-dependencies]
insta = { version = "1.40.0" } insta = { version = "1.40.0" }
serde_json = { workspace = true } serde_json = { version = "1.0.128" }
tracing-test = { version = "0.2.5" } tracing-test = { version = "0.2.5" }
[features] [features]

View file

@ -16,8 +16,6 @@
#![warn(missing_docs)] #![warn(missing_docs)]
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::error::Error; use std::error::Error;
use std::fmt::{Debug, Display, Formatter}; use std::fmt::{Debug, Display, Formatter};
use std::path::Path; use std::path::Path;
@ -336,15 +334,22 @@ impl Reporter for TracingReporter {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl<T: Pep508Url> schemars::JsonSchema for Requirement<T> { impl<T: Pep508Url> schemars::JsonSchema for Requirement<T> {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("Requirement") "Requirement".to_string()
} }
fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ schemars::schema::SchemaObject {
"type": "string", instance_type: Some(schemars::schema::InstanceType::String.into()),
"description": "A PEP 508 dependency specifier, e.g., `ruff >= 0.6.0`" metadata: Some(Box::new(schemars::schema::Metadata {
}) description: Some(
"A PEP 508 dependency specifier, e.g., `ruff >= 0.6.0`".to_string(),
),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
} }
} }

View file

@ -172,7 +172,7 @@ impl InternerGuard<'_> {
), ),
// Normalize `python_version` markers to `python_full_version` nodes. // Normalize `python_version` markers to `python_full_version` nodes.
MarkerValueVersion::PythonVersion => { MarkerValueVersion::PythonVersion => {
match python_version_to_full_version(specifier.only_release()) { match python_version_to_full_version(normalize_specifier(specifier)) {
Ok(specifier) => ( Ok(specifier) => (
Variable::Version(CanonicalMarkerValueVersion::PythonFullVersion), Variable::Version(CanonicalMarkerValueVersion::PythonFullVersion),
Edges::from_specifier(specifier), Edges::from_specifier(specifier),
@ -1214,7 +1214,7 @@ impl Edges {
/// Returns the [`Edges`] for a version specifier. /// Returns the [`Edges`] for a version specifier.
fn from_specifier(specifier: VersionSpecifier) -> Edges { fn from_specifier(specifier: VersionSpecifier) -> Edges {
let specifier = release_specifier_to_range(specifier.only_release(), true); let specifier = release_specifier_to_range(normalize_specifier(specifier));
Edges::Version { Edges::Version {
edges: Edges::from_range(&specifier), edges: Edges::from_range(&specifier),
} }
@ -1227,9 +1227,9 @@ impl Edges {
let mut range: Ranges<Version> = versions let mut range: Ranges<Version> = versions
.into_iter() .into_iter()
.map(|version| { .map(|version| {
let specifier = VersionSpecifier::equals_version(version.only_release()); let specifier = VersionSpecifier::equals_version(version.clone());
let specifier = python_version_to_full_version(specifier)?; let specifier = python_version_to_full_version(specifier)?;
Ok(release_specifier_to_range(specifier, true)) Ok(release_specifier_to_range(normalize_specifier(specifier)))
}) })
.flatten_ok() .flatten_ok()
.collect::<Result<Ranges<_>, NodeId>>()?; .collect::<Result<Ranges<_>, NodeId>>()?;
@ -1526,62 +1526,57 @@ impl Edges {
} }
} }
// Normalize a [`VersionSpecifier`] before adding it to the tree.
fn normalize_specifier(specifier: VersionSpecifier) -> VersionSpecifier {
let (operator, version) = specifier.into_parts();
// The decision diagram relies on the assumption that the negation of a marker tree is
// the complement of the marker space. However, pre-release versions violate this assumption.
//
// For example, the marker `python_full_version > '3.9' or python_full_version <= '3.9'`
// does not match `python_full_version == 3.9.0a0` and so cannot simplify to `true`. However,
// its negation, `python_full_version > '3.9' and python_full_version <= '3.9'`, also does not
// match `3.9.0a0` and simplifies to `false`, which violates the algebra decision diagrams
// rely on. For this reason we ignore pre-release versions entirely when evaluating markers.
//
// Note that `python_version` cannot take on pre-release values as it is truncated to just the
// major and minor version segments. Thus using release-only specifiers is definitely necessary
// for `python_version` to fully simplify any ranges, such as `python_version > '3.9' or python_version <= '3.9'`,
// which is always `true` for `python_version`. For `python_full_version` however, this decision
// is a semantic change.
let mut release = &*version.release();
// Strip any trailing `0`s.
//
// The [`Version`] type ignores trailing `0`s for equality, but still preserves them in its
// [`Display`] output. We must normalize all versions by stripping trailing `0`s to remove the
// distinction between versions like `3.9` and `3.9.0`. Otherwise, their output would depend on
// which form was added to the global marker interner first.
//
// Note that we cannot strip trailing `0`s for star equality, as `==3.0.*` is different from `==3.*`.
if !operator.is_star() {
if let Some(end) = release.iter().rposition(|segment| *segment != 0) {
if end > 0 {
release = &release[..=end];
}
}
}
VersionSpecifier::from_version(operator, Version::new(release)).unwrap()
}
/// Returns the equivalent `python_full_version` specifier for a `python_version` specifier. /// Returns the equivalent `python_full_version` specifier for a `python_version` specifier.
/// ///
/// Returns `Err` with a constant node if the equivalent comparison is always `true` or `false`. /// Returns `Err` with a constant node if the equivalent comparison is always `true` or `false`.
fn python_version_to_full_version(specifier: VersionSpecifier) -> Result<VersionSpecifier, NodeId> { fn python_version_to_full_version(specifier: VersionSpecifier) -> Result<VersionSpecifier, NodeId> {
// Trailing zeroes matter only for (not-)equals-star and tilde-equals. This means that below
// the next two blocks, we can use the trimmed release as the release.
if specifier.operator().is_star() {
// Input python_version python_full_version
// ==3.* 3.* 3.*
// ==3.0.* 3.0 3.0.*
// ==3.0.0.* 3.0 3.0.*
// ==3.9.* 3.9 3.9.*
// ==3.9.0.* 3.9 3.9.*
// ==3.9.0.0.* 3.9 3.9.*
// ==3.9.1.* FALSE FALSE
// ==3.9.1.0.* FALSE FALSE
// ==3.9.1.0.0.* FALSE FALSE
return match &*specifier.version().release() {
// `3.*`
[_major] => Ok(specifier),
// Ex) `3.9.*`, `3.9.0.*`, or `3.9.0.0.*`
[major, minor, rest @ ..] if rest.iter().all(|x| *x == 0) => {
let python_version = Version::new([major, minor]);
// Unwrap safety: A star operator with two version segments is always valid.
Ok(VersionSpecifier::from_version(*specifier.operator(), python_version).unwrap())
}
// Ex) `3.9.1.*` or `3.9.0.1.*`
_ => Err(NodeId::FALSE),
};
}
if *specifier.operator() == Operator::TildeEqual {
// python_version python_full_version
// ~=3 (not possible)
// ~= 3.0 >= 3.0, < 4.0
// ~= 3.9 >= 3.9, < 4.0
// ~= 3.9.0 == 3.9.*
// ~= 3.9.1 FALSE
// ~= 3.9.0.0 == 3.9.*
// ~= 3.9.0.1 FALSE
return match &*specifier.version().release() {
// Ex) `3.0`, `3.7`
[_major, _minor] => Ok(specifier),
// Ex) `3.9`, `3.9.0`, or `3.9.0.0`
[major, minor, rest @ ..] if rest.iter().all(|x| *x == 0) => {
let python_version = Version::new([major, minor]);
Ok(VersionSpecifier::equals_star_version(python_version))
}
// Ex) `3.9.1` or `3.9.0.1`
_ => Err(NodeId::FALSE),
};
}
// Extract the major and minor version segments if the specifier contains exactly // Extract the major and minor version segments if the specifier contains exactly
// those segments, or if it contains a major segment with an implied minor segment of `0`. // those segments, or if it contains a major segment with an implied minor segment of `0`.
let major_minor = match *specifier.version().only_release_trimmed().release() { let major_minor = match *specifier.version().release() {
// For star operators, we cannot add a trailing `0`.
//
// `python_version == 3.*` is equivalent to `python_full_version == 3.*`. Adding a
// trailing `0` would result in `python_version == 3.0.*`, which is incorrect.
[_major] if specifier.operator().is_star() => return Ok(specifier),
// Add a trailing `0` for the minor version, which is implied. // Add a trailing `0` for the minor version, which is implied.
// For example, `python_version == 3` matches `3.0.1`, `3.0.2`, etc. // For example, `python_version == 3` matches `3.0.1`, `3.0.2`, etc.
[major] => Some((major, 0)), [major] => Some((major, 0)),
@ -1619,10 +1614,9 @@ fn python_version_to_full_version(specifier: VersionSpecifier) -> Result<Version
VersionSpecifier::less_than_version(Version::new([major, minor + 1])) VersionSpecifier::less_than_version(Version::new([major, minor + 1]))
} }
Operator::EqualStar | Operator::NotEqualStar | Operator::TildeEqual => { // `==3.7.*`, `!=3.7.*`, `~=3.7` already represent the equivalent `python_full_version`
// Handled above. // comparison.
unreachable!() Operator::EqualStar | Operator::NotEqualStar | Operator::TildeEqual => specifier,
}
}) })
} else { } else {
let [major, minor, ..] = *specifier.version().release() else { let [major, minor, ..] = *specifier.version().release() else {
@ -1630,14 +1624,13 @@ fn python_version_to_full_version(specifier: VersionSpecifier) -> Result<Version
}; };
Ok(match specifier.operator() { Ok(match specifier.operator() {
// `python_version` cannot have more than two release segments, and we know // `python_version` cannot have more than two release segments, so equality is impossible.
// that the following release segments aren't purely zeroes so equality is impossible. Operator::Equal | Operator::ExactEqual | Operator::EqualStar | Operator::TildeEqual => {
Operator::Equal | Operator::ExactEqual => {
return Err(NodeId::FALSE); return Err(NodeId::FALSE);
} }
// Similarly, inequalities are always `true`. // Similarly, inequalities are always `true`.
Operator::NotEqual => return Err(NodeId::TRUE), Operator::NotEqual | Operator::NotEqualStar => return Err(NodeId::TRUE),
// `python_version {<,<=} 3.7.8` is equivalent to `python_full_version < 3.8`. // `python_version {<,<=} 3.7.8` is equivalent to `python_full_version < 3.8`.
Operator::LessThan | Operator::LessThanEqual => { Operator::LessThan | Operator::LessThanEqual => {
@ -1648,11 +1641,6 @@ fn python_version_to_full_version(specifier: VersionSpecifier) -> Result<Version
Operator::GreaterThan | Operator::GreaterThanEqual => { Operator::GreaterThan | Operator::GreaterThanEqual => {
VersionSpecifier::greater_than_equal_version(Version::new([major, minor + 1])) VersionSpecifier::greater_than_equal_version(Version::new([major, minor + 1]))
} }
Operator::EqualStar | Operator::NotEqualStar | Operator::TildeEqual => {
// Handled above.
unreachable!()
}
}) })
} }
} }

View file

@ -64,8 +64,8 @@ fn collect_dnf(
continue; continue;
} }
// Detect whether the range for this edge can be simplified as a star specifier. // Detect whether the range for this edge can be simplified as a star inequality.
if let Some(specifier) = star_range_specifier(&range) { if let Some(specifier) = star_range_inequality(&range) {
path.push(MarkerExpression::Version { path.push(MarkerExpression::Version {
key: marker.key().into(), key: marker.key().into(),
specifier, specifier,
@ -343,34 +343,22 @@ where
Some(excluded) Some(excluded)
} }
/// Returns `Some` if the version range can be simplified as a star specifier. /// Returns `Some` if the version expression can be simplified as a star inequality with the given
/// specifier.
/// ///
/// Only for the two bounds case not covered by [`VersionSpecifier::from_release_only_bounds`]. /// For example, `python_full_version < '3.8' or python_full_version >= '3.9'` can be simplified to
/// /// `python_full_version != '3.8.*'`.
/// For negative ranges like `python_full_version < '3.8' or python_full_version >= '3.9'`, fn star_range_inequality(range: &Ranges<Version>) -> Option<VersionSpecifier> {
/// returns `!= '3.8.*'`.
fn star_range_specifier(range: &Ranges<Version>) -> Option<VersionSpecifier> {
if range.iter().count() != 2 {
return None;
}
// Check for negative star range: two segments [(Unbounded, Excluded(v1)), (Included(v2), Unbounded)]
let (b1, b2) = range.iter().collect_tuple()?; let (b1, b2) = range.iter().collect_tuple()?;
if let ((Bound::Unbounded, Bound::Excluded(v1)), (Bound::Included(v2), Bound::Unbounded)) =
(b1, b2) match (b1, b2) {
{ ((Bound::Unbounded, Bound::Excluded(v1)), (Bound::Included(v2), Bound::Unbounded))
match *v1.only_release_trimmed().release() { if v1.release().len() == 2
[major] if *v2.release() == [major, 1] => { && *v2.release() == [v1.release()[0], v1.release()[1] + 1] =>
Some(VersionSpecifier::not_equals_star_version(Version::new([ {
major, 0, Some(VersionSpecifier::not_equals_star_version(v1.clone()))
])))
}
[major, minor] if *v2.release() == [major, minor + 1] => {
Some(VersionSpecifier::not_equals_star_version(v1.clone()))
}
_ => None,
} }
} else { _ => None,
None
} }
} }

View file

@ -1707,15 +1707,23 @@ impl Display for MarkerTreeContents {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for MarkerTree { impl schemars::JsonSchema for MarkerTree {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("MarkerTree") "MarkerTree".to_string()
} }
fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ schemars::schema::SchemaObject {
"type": "string", instance_type: Some(schemars::schema::InstanceType::String.into()),
"description": "A PEP 508-compliant marker expression, e.g., `sys_platform == 'Darwin'`" metadata: Some(Box::new(schemars::schema::Metadata {
}) description: Some(
"A PEP 508-compliant marker expression, e.g., `sys_platform == 'Darwin'`"
.to_string(),
),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
} }
} }
@ -2271,13 +2279,13 @@ mod test {
#[test] #[test]
fn test_marker_simplification() { fn test_marker_simplification() {
assert_false("python_version == '3.9.1'"); assert_false("python_version == '3.9.1'");
assert_false("python_version == '3.9.0.*'");
assert_true("python_version != '3.9.1'"); assert_true("python_version != '3.9.1'");
// This is an edge case that happens to be supported, but is not critical to support. // Technically these is are valid substring comparison, but we do not allow them.
assert_simplifies( // e.g., using a version with patch components with `python_version` is considered
"python_version in '3.9.0'", // impossible to satisfy since the value it is truncated at the minor version
"python_full_version == '3.9.*'", assert_false("python_version in '3.9.0'");
);
// e.g., using a version that is not PEP 440 compliant is considered arbitrary // e.g., using a version that is not PEP 440 compliant is considered arbitrary
assert_true("python_version in 'foo'"); assert_true("python_version in 'foo'");
// e.g., including `*` versions, which would require tracking a version specifier // e.g., including `*` versions, which would require tracking a version specifier
@ -2287,25 +2295,16 @@ mod test {
assert_true("python_version in '3.9,3.10'"); assert_true("python_version in '3.9,3.10'");
assert_true("python_version in '3.9 or 3.10'"); assert_true("python_version in '3.9 or 3.10'");
// This is an edge case that happens to be supported, but is not critical to support. // e.g, when one of the values cannot be true
assert_simplifies( // TODO(zanieb): This seems like a quirk of the `python_full_version` normalization, this
"python_version in '3.9 3.10.0 3.11'", // should just act as though the patch version isn't present
"python_full_version >= '3.9' and python_full_version < '3.12'", assert_false("python_version in '3.9 3.10.0 3.11'");
);
assert_simplifies("python_version == '3.9'", "python_full_version == '3.9.*'"); assert_simplifies("python_version == '3.9'", "python_full_version == '3.9.*'");
assert_simplifies( assert_simplifies(
"python_version == '3.9.0'", "python_version == '3.9.0'",
"python_full_version == '3.9.*'", "python_full_version == '3.9.*'",
); );
assert_simplifies(
"python_version == '3.9.0.*'",
"python_full_version == '3.9.*'",
);
assert_simplifies(
"python_version == '3.*'",
"python_full_version >= '3' and python_full_version < '4'",
);
// `<version> in` // `<version> in`
// e.g., when the range is not contiguous // e.g., when the range is not contiguous
@ -2516,7 +2515,7 @@ mod test {
#[test] #[test]
fn test_simplification_extra_versus_other() { fn test_simplification_extra_versus_other() {
// Here, the `extra != 'foo'` cannot be simplified out, because // Here, the `extra != 'foo'` cannot be simplified out, because
// `extra == 'foo'` can be true even when `extra == 'bar'`' is true. // `extra == 'foo'` can be true even when `extra == 'bar`' is true.
assert_simplifies( assert_simplifies(
r#"extra != "foo" and (extra == "bar" or extra == "baz")"#, r#"extra != "foo" and (extra == "bar" or extra == "baz")"#,
"(extra == 'bar' and extra != 'foo') or (extra == 'baz' and extra != 'foo')", "(extra == 'bar' and extra != 'foo') or (extra == 'baz' and extra != 'foo')",
@ -2537,68 +2536,6 @@ mod test {
); );
} }
#[test]
fn test_python_version_equal_star() {
// Input, equivalent with python_version, equivalent with python_full_version
let cases = [
("3.*", "3.*", "3.*"),
("3.0.*", "3.0", "3.0.*"),
("3.0.0.*", "3.0", "3.0.*"),
("3.9.*", "3.9", "3.9.*"),
("3.9.0.*", "3.9", "3.9.*"),
("3.9.0.0.*", "3.9", "3.9.*"),
];
for (input, equal_python_version, equal_python_full_version) in cases {
assert_eq!(
m(&format!("python_version == '{input}'")),
m(&format!("python_version == '{equal_python_version}'")),
"{input} {equal_python_version}"
);
assert_eq!(
m(&format!("python_version == '{input}'")),
m(&format!(
"python_full_version == '{equal_python_full_version}'"
)),
"{input} {equal_python_full_version}"
);
}
let cases_false = ["3.9.1.*", "3.9.1.0.*", "3.9.1.0.0.*"];
for input in cases_false {
assert!(
m(&format!("python_version == '{input}'")).is_false(),
"{input}"
);
}
}
#[test]
fn test_tilde_equal_normalization() {
assert_eq!(
m("python_version ~= '3.10.0'"),
m("python_version >= '3.10.0' and python_version < '3.11.0'")
);
// Two digit versions such as `python_version` get padded with a zero, so they can never
// match
assert_eq!(m("python_version ~= '3.10.1'"), MarkerTree::FALSE);
assert_eq!(
m("python_version ~= '3.10'"),
m("python_version >= '3.10' and python_version < '4.0'")
);
assert_eq!(
m("python_full_version ~= '3.10.0'"),
m("python_full_version >= '3.10.0' and python_full_version < '3.11.0'")
);
assert_eq!(
m("python_full_version ~= '3.10'"),
m("python_full_version >= '3.10' and python_full_version < '4.0'")
);
}
/// This tests marker implication. /// This tests marker implication.
/// ///
/// Specifically, these test cases come from a [bug] where `foo` and `bar` /// Specifically, these test cases come from a [bug] where `foo` and `bar`
@ -3395,32 +3332,4 @@ mod test {
] ]
); );
} }
/// Case a: There is no version `3` (no trailing zero) in the interner yet.
#[test]
fn marker_normalization_a() {
let left_tree = m("python_version == '3.0.*'");
let left = left_tree.try_to_string().unwrap();
let right = "python_full_version == '3.0.*'";
assert_eq!(left, right, "{left} != {right}");
}
/// Case b: There is already a version `3` (no trailing zero) in the interner.
#[test]
fn marker_normalization_b() {
m("python_version >= '3' and python_version <= '3.0'");
let left_tree = m("python_version == '3.0.*'");
let left = left_tree.try_to_string().unwrap();
let right = "python_full_version == '3.0.*'";
assert_eq!(left, right, "{left} != {right}");
}
#[test]
fn marker_normalization_c() {
let left_tree = MarkerTree::from_str("python_version == '3.10.0.*'").unwrap();
let left = left_tree.try_to_string().unwrap();
let right = "python_full_version == '3.10.*'";
assert_eq!(left, right, "{left} != {right}");
}
} }

View file

@ -18,16 +18,11 @@ use uv_redacted::DisplaySafeUrl;
use crate::Pep508Url; use crate::Pep508Url;
/// A wrapper around [`Url`] that preserves the original string. /// A wrapper around [`Url`] that preserves the original string.
///
/// The original string is not preserved after serialization/deserialization.
#[derive(Debug, Clone, Eq)] #[derive(Debug, Clone, Eq)]
pub struct VerbatimUrl { pub struct VerbatimUrl {
/// The parsed URL. /// The parsed URL.
url: DisplaySafeUrl, url: DisplaySafeUrl,
/// The URL as it was provided by the user. /// The URL as it was provided by the user.
///
/// Even if originally set, this will be [`None`] after
/// serialization/deserialization.
given: Option<ArcStr>, given: Option<ArcStr>,
} }

View file

@ -758,14 +758,6 @@ impl FormMetadata {
} }
} }
impl<'a> IntoIterator for &'a FormMetadata {
type Item = &'a (&'a str, String);
type IntoIter = std::slice::Iter<'a, (&'a str, String)>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
/// Build the upload request. /// Build the upload request.
/// ///
/// Returns the request and the reporter progress bar id. /// Returns the request and the reporter progress bar id.

View file

@ -1,6 +1,37 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
/// Join a relative URL to a base URL.
pub fn base_url_join_relative(
base: &str,
relative: &str,
) -> Result<DisplaySafeUrl, JoinRelativeError> {
let base_url = DisplaySafeUrl::parse(base).map_err(|err| JoinRelativeError::ParseError {
original: base.to_string(),
source: err,
})?;
base_url
.join(relative)
.map_err(|err| JoinRelativeError::ParseError {
original: format!("{base}/{relative}"),
source: err,
})
}
/// An error that occurs when `base_url_join_relative` fails.
///
/// The error message includes the URL (`base` or `maybe_relative`) passed to
/// `base_url_join_relative` that provoked the error.
#[derive(Clone, Debug, thiserror::Error)]
pub enum JoinRelativeError {
#[error("Failed to parse URL: `{original}`")]
ParseError {
original: String,
source: url::ParseError,
},
}
#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
pub struct BaseUrl( pub struct BaseUrl(
#[serde( #[serde(

View file

@ -3,8 +3,6 @@ use petgraph::{
graph::{DiGraph, NodeIndex}, graph::{DiGraph, NodeIndex},
}; };
use rustc_hash::{FxHashMap, FxHashSet}; use rustc_hash::{FxHashMap, FxHashSet};
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::{collections::BTreeSet, hash::Hash, rc::Rc}; use std::{collections::BTreeSet, hash::Hash, rc::Rc};
use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_normalize::{ExtraName, GroupName, PackageName};
@ -640,12 +638,12 @@ pub struct SchemaConflictItem {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for SchemaConflictItem { impl schemars::JsonSchema for SchemaConflictItem {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("SchemaConflictItem") "SchemaConflictItem".to_string()
} }
fn json_schema(generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(r#gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
<ConflictItemWire as schemars::JsonSchema>::json_schema(generator) <ConflictItemWire as schemars::JsonSchema>::json_schema(r#gen)
} }
} }

View file

@ -1,6 +1,4 @@
use serde::{Serialize, Serializer}; use serde::{Serialize, Serializer};
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::fmt::Display; use std::fmt::Display;
use std::str::FromStr; use std::str::FromStr;
use thiserror::Error; use thiserror::Error;
@ -101,16 +99,25 @@ impl Serialize for Identifier {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for Identifier { impl schemars::JsonSchema for Identifier {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("Identifier") "Identifier".to_string()
} }
fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ schemars::schema::SchemaObject {
"type": "string", instance_type: Some(schemars::schema::InstanceType::String.into()),
"pattern": r"^[_\p{Alphabetic}][_0-9\p{Alphabetic}]*$", string: Some(Box::new(schemars::schema::StringValidation {
"description": "An identifier in Python" // Best-effort Unicode support (https://stackoverflow.com/a/68844380/3549270)
}) pattern: Some(r"^[_\p{Alphabetic}][_0-9\p{Alphabetic}]*$".to_string()),
..schemars::schema::StringValidation::default()
})),
metadata: Some(Box::new(schemars::schema::Metadata {
description: Some("An identifier in Python".to_string()),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1433,7 +1433,7 @@ pub(crate) fn is_windows_store_shim(path: &Path) -> bool {
0, 0,
buf.as_mut_ptr().cast(), buf.as_mut_ptr().cast(),
buf.len() as u32 * 2, buf.len() as u32 * 2,
&raw mut bytes_returned, &mut bytes_returned,
std::ptr::null_mut(), std::ptr::null_mut(),
) != 0 ) != 0
}; };

View file

@ -12,7 +12,7 @@ use futures::TryStreamExt;
use itertools::Itertools; use itertools::Itertools;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use reqwest_retry::{RetryError, RetryPolicy}; use reqwest_retry::RetryPolicy;
use serde::Deserialize; use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
use tokio::io::{AsyncRead, AsyncWriteExt, BufWriter, ReadBuf}; use tokio::io::{AsyncRead, AsyncWriteExt, BufWriter, ReadBuf};
@ -111,33 +111,6 @@ pub enum Error {
}, },
} }
impl Error {
// Return the number of attempts that were made to complete this request before this error was
// returned. Note that e.g. 3 retries equates to 4 attempts.
//
// It's easier to do arithmetic with "attempts" instead of "retries", because if you have
// nested retry loops you can just add up all the attempts directly, while adding up the
// retries requires +1/-1 adjustments.
fn attempts(&self) -> u32 {
// Unfortunately different variants of `Error` track retry counts in different ways. We
// could consider unifying the variants we handle here in `Error::from_reqwest_middleware`
// instead, but both approaches will be fragile as new variants get added over time.
if let Error::NetworkErrorWithRetries { retries, .. } = self {
return retries + 1;
}
// TODO(jack): let-chains are stable as of Rust 1.88. We should use them here as soon as
// our rust-version is high enough.
if let Error::NetworkMiddlewareError(_, anyhow_error) = self {
if let Some(RetryError::WithRetries { retries, .. }) =
anyhow_error.downcast_ref::<RetryError>()
{
return retries + 1;
}
}
1
}
}
#[derive(Debug, PartialEq, Eq, Clone, Hash)] #[derive(Debug, PartialEq, Eq, Clone, Hash)]
pub struct ManagedPythonDownload { pub struct ManagedPythonDownload {
key: PythonInstallationKey, key: PythonInstallationKey,
@ -722,8 +695,7 @@ impl ManagedPythonDownload {
pypy_install_mirror: Option<&str>, pypy_install_mirror: Option<&str>,
reporter: Option<&dyn Reporter>, reporter: Option<&dyn Reporter>,
) -> Result<DownloadResult, Error> { ) -> Result<DownloadResult, Error> {
let mut total_attempts = 0; let mut n_past_retries = 0;
let mut retried_here = false;
let start_time = SystemTime::now(); let start_time = SystemTime::now();
let retry_policy = client.retry_policy(); let retry_policy = client.retry_policy();
loop { loop {
@ -738,41 +710,25 @@ impl ManagedPythonDownload {
reporter, reporter,
) )
.await; .await;
let result = match result { if result
Ok(download_result) => Ok(download_result), .as_ref()
Err(err) => { .err()
// Inner retry loops (e.g. `reqwest-retry` middleware) might make more than one .is_some_and(|err| is_extended_transient_error(err))
// attempt per error we see here. {
total_attempts += err.attempts(); let retry_decision = retry_policy.should_retry(start_time, n_past_retries);
// We currently interpret e.g. "3 retries" to mean we should make 4 attempts. if let reqwest_retry::RetryDecision::Retry { execute_after } = retry_decision {
let n_past_retries = total_attempts - 1; debug!(
if is_extended_transient_error(&err) { "Transient failure while handling response for {}; retrying...",
let retry_decision = retry_policy.should_retry(start_time, n_past_retries); self.key()
if let reqwest_retry::RetryDecision::Retry { execute_after } = );
retry_decision let duration = execute_after
{ .duration_since(SystemTime::now())
debug!( .unwrap_or_else(|_| Duration::default());
"Transient failure while handling response for {}; retrying...", tokio::time::sleep(duration).await;
self.key() n_past_retries += 1;
); continue;
let duration = execute_after
.duration_since(SystemTime::now())
.unwrap_or_else(|_| Duration::default());
tokio::time::sleep(duration).await;
retried_here = true;
continue; // Retry.
}
}
if retried_here {
Err(Error::NetworkErrorWithRetries {
err: Box::new(err),
retries: n_past_retries,
})
} else {
Err(err)
}
} }
}; }
return result; return result;
} }
} }
@ -816,9 +772,7 @@ impl ManagedPythonDownload {
let temp_dir = tempfile::tempdir_in(scratch_dir).map_err(Error::DownloadDirError)?; let temp_dir = tempfile::tempdir_in(scratch_dir).map_err(Error::DownloadDirError)?;
if let Some(python_builds_dir) = if let Some(python_builds_dir) = env::var_os(EnvVars::UV_PYTHON_CACHE_DIR) {
env::var_os(EnvVars::UV_PYTHON_CACHE_DIR).filter(|s| !s.is_empty())
{
let python_builds_dir = PathBuf::from(python_builds_dir); let python_builds_dir = PathBuf::from(python_builds_dir);
fs_err::create_dir_all(&python_builds_dir)?; fs_err::create_dir_all(&python_builds_dir)?;
let hash_prefix = match self.sha256 { let hash_prefix = match self.sha256 {

View file

@ -44,13 +44,6 @@ impl ImplementationName {
Self::GraalPy => "GraalPy", Self::GraalPy => "GraalPy",
} }
} }
pub fn executable_name(self) -> &'static str {
match self {
Self::CPython => "python",
Self::PyPy | Self::GraalPy => self.into(),
}
}
} }
impl LenientImplementationName { impl LenientImplementationName {
@ -60,13 +53,6 @@ impl LenientImplementationName {
Self::Unknown(name) => name, Self::Unknown(name) => name,
} }
} }
pub fn executable_name(&self) -> &str {
match self {
Self::Known(implementation) => implementation.executable_name(),
Self::Unknown(name) => name,
}
}
} }
impl From<&ImplementationName> for &'static str { impl From<&ImplementationName> for &'static str {

View file

@ -107,9 +107,17 @@ impl PythonInstallation {
Err(err) => err, Err(err) => err,
}; };
let downloads_enabled = preference.allows_managed()
&& python_downloads.is_automatic()
&& client_builder.connectivity.is_online();
if !downloads_enabled {
return Err(err);
}
match err { match err {
// If Python is missing, we should attempt a download // If Python is missing, we should attempt a download
Error::MissingPython(..) => {} Error::MissingPython(_) => {}
// If we raised a non-critical error, we should attempt a download // If we raised a non-critical error, we should attempt a download
Error::Discovery(ref err) if !err.is_critical() => {} Error::Discovery(ref err) if !err.is_critical() => {}
// Otherwise, this is fatal // Otherwise, this is fatal
@ -117,109 +125,40 @@ impl PythonInstallation {
} }
// If we can't convert the request to a download, throw the original error // If we can't convert the request to a download, throw the original error
let Some(download_request) = PythonDownloadRequest::from_request(request) else { let Some(request) = PythonDownloadRequest::from_request(request) else {
return Err(err); return Err(err);
}; };
let downloads_enabled = preference.allows_managed() debug!("Requested Python not found, checking for available download...");
&& python_downloads.is_automatic() match Self::fetch(
&& client_builder.connectivity.is_online(); request.fill()?,
let download = download_request.clone().fill().map(|request| {
ManagedPythonDownload::from_request(&request, python_downloads_json_url)
});
// Regardless of whether downloads are enabled, we want to determine if the download is
// available to power error messages. However, if downloads aren't enabled, we don't want to
// report any errors related to them.
let download = match download {
Ok(Ok(download)) => Some(download),
// If the download cannot be found, return the _original_ discovery error
Ok(Err(downloads::Error::NoDownloadFound(_))) => {
if downloads_enabled {
debug!("No downloads are available for {request}");
return Err(err);
}
None
}
Err(err) | Ok(Err(err)) => {
if downloads_enabled {
// We failed to determine the platform information
return Err(err.into());
}
None
}
};
let Some(download) = download else {
// N.B. We should only be in this case when downloads are disabled; when downloads are
// enabled, we should fail eagerly when something goes wrong with the download.
debug_assert!(!downloads_enabled);
return Err(err);
};
// If the download is available, but not usable, we attach a hint to the original error.
if !downloads_enabled {
let for_request = match request {
PythonRequest::Default | PythonRequest::Any => String::new(),
_ => format!(" for {request}"),
};
match python_downloads {
PythonDownloads::Automatic => {}
PythonDownloads::Manual => {
return Err(err.with_missing_python_hint(format!(
"A managed Python download is available{for_request}, but Python downloads are set to 'manual', use `uv python install {}` to install the required version",
request.to_canonical_string(),
)));
}
PythonDownloads::Never => {
return Err(err.with_missing_python_hint(format!(
"A managed Python download is available{for_request}, but Python downloads are set to 'never'"
)));
}
}
match preference {
PythonPreference::OnlySystem => {
return Err(err.with_missing_python_hint(format!(
"A managed Python download is available{for_request}, but the Python preference is set to 'only system'"
)));
}
PythonPreference::Managed
| PythonPreference::OnlyManaged
| PythonPreference::System => {}
}
if !client_builder.connectivity.is_online() {
return Err(err.with_missing_python_hint(format!(
"A managed Python download is available{for_request}, but uv is set to offline mode"
)));
}
return Err(err);
}
Self::fetch(
download,
client_builder, client_builder,
cache, cache,
reporter, reporter,
python_install_mirror, python_install_mirror,
pypy_install_mirror, pypy_install_mirror,
python_downloads_json_url,
preview, preview,
) )
.await .await
{
Ok(installation) => Ok(installation),
// Throw the original error if we couldn't find a download
Err(Error::Download(downloads::Error::NoDownloadFound(_))) => Err(err),
// But if the download failed, throw that error
Err(err) => Err(err),
}
} }
/// Download and install the requested installation. /// Download and install the requested installation.
pub async fn fetch( pub async fn fetch(
download: &'static ManagedPythonDownload, request: PythonDownloadRequest,
client_builder: &BaseClientBuilder<'_>, client_builder: &BaseClientBuilder<'_>,
cache: &Cache, cache: &Cache,
reporter: Option<&dyn Reporter>, reporter: Option<&dyn Reporter>,
python_install_mirror: Option<&str>, python_install_mirror: Option<&str>,
pypy_install_mirror: Option<&str>, pypy_install_mirror: Option<&str>,
python_downloads_json_url: Option<&str>,
preview: PreviewMode, preview: PreviewMode,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let installations = ManagedPythonInstallations::from_settings(None)?.init()?; let installations = ManagedPythonInstallations::from_settings(None)?.init()?;
@ -227,6 +166,7 @@ impl PythonInstallation {
let scratch_dir = installations.scratch(); let scratch_dir = installations.scratch();
let _lock = installations.lock().await?; let _lock = installations.lock().await?;
let download = ManagedPythonDownload::from_request(&request, python_downloads_json_url)?;
let client = client_builder.build(); let client = client_builder.build();
info!("Fetching requested Python..."); info!("Fetching requested Python...");

View file

@ -967,31 +967,6 @@ impl InterpreterInfo {
pub(crate) fn query_cached(executable: &Path, cache: &Cache) -> Result<Self, Error> { pub(crate) fn query_cached(executable: &Path, cache: &Cache) -> Result<Self, Error> {
let absolute = std::path::absolute(executable)?; let absolute = std::path::absolute(executable)?;
// Provide a better error message if the link is broken or the file does not exist. Since
// `canonicalize_executable` does not resolve the file on Windows, we must re-use this logic
// for the subsequent metadata read as we may not have actually resolved the path.
let handle_io_error = |err: io::Error| -> Error {
if err.kind() == io::ErrorKind::NotFound {
// Check if it looks like a venv interpreter where the underlying Python
// installation was removed.
if absolute
.symlink_metadata()
.is_ok_and(|metadata| metadata.is_symlink())
{
Error::BrokenSymlink(BrokenSymlink {
path: executable.to_path_buf(),
venv: uv_fs::is_virtualenv_executable(executable),
})
} else {
Error::NotFound(executable.to_path_buf())
}
} else {
err.into()
}
};
let canonical = canonicalize_executable(&absolute).map_err(handle_io_error)?;
let cache_entry = cache.entry( let cache_entry = cache.entry(
CacheBucket::Interpreter, CacheBucket::Interpreter,
// Shard interpreter metadata by host architecture, operating system, and version, to // Shard interpreter metadata by host architecture, operating system, and version, to
@ -1002,18 +977,33 @@ impl InterpreterInfo {
sys_info::os_release().unwrap_or_default(), sys_info::os_release().unwrap_or_default(),
)), )),
// We use the absolute path for the cache entry to avoid cache collisions for relative // We use the absolute path for the cache entry to avoid cache collisions for relative
// paths. But we don't want to query the executable with symbolic links resolved because // paths. But we don't to query the executable with symbolic links resolved.
// that can change reported values, e.g., `sys.executable`. We include the canonical format!("{}.msgpack", cache_digest(&absolute)),
// path in the cache entry as well, otherwise we can have cache collisions if an
// absolute path refers to different interpreters with matching ctimes, e.g., if you
// have a `.venv/bin/python` pointing to both Python 3.12 and Python 3.13 that were
// modified at the same time.
format!("{}.msgpack", cache_digest(&(&absolute, &canonical))),
); );
// We check the timestamp of the canonicalized executable to check if an underlying // We check the timestamp of the canonicalized executable to check if an underlying
// interpreter has been modified. // interpreter has been modified.
let modified = Timestamp::from_path(canonical).map_err(handle_io_error)?; let modified = canonicalize_executable(&absolute)
.and_then(Timestamp::from_path)
.map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
// Check if it looks like a venv interpreter where the underlying Python
// installation was removed.
if absolute
.symlink_metadata()
.is_ok_and(|metadata| metadata.is_symlink())
{
Error::BrokenSymlink(BrokenSymlink {
path: executable.to_path_buf(),
venv: uv_fs::is_virtualenv_executable(executable),
})
} else {
Error::NotFound(executable.to_path_buf())
}
} else {
err.into()
}
})?;
// Read from the cache. // Read from the cache.
if cache if cache
@ -1025,7 +1015,7 @@ impl InterpreterInfo {
Ok(cached) => { Ok(cached) => {
if cached.timestamp == modified { if cached.timestamp == modified {
trace!( trace!(
"Found cached interpreter info for Python {}, skipping query of: {}", "Cached interpreter info for Python {}, skipping probing: {}",
cached.data.markers.python_full_version(), cached.data.markers.python_full_version(),
executable.user_display() executable.user_display()
); );

View file

@ -1,5 +1,4 @@
//! Find requested Python interpreters and query interpreters for information. //! Find requested Python interpreters and query interpreters for information.
use owo_colors::OwoColorize;
use thiserror::Error; use thiserror::Error;
#[cfg(test)] #[cfg(test)]
@ -94,8 +93,8 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
KeyError(#[from] installation::PythonInstallationKeyError), KeyError(#[from] installation::PythonInstallationKeyError),
#[error("{}{}", .0, if let Some(hint) = .1 { format!("\n\n{}{} {hint}", "hint".bold().cyan(), ":".bold()) } else { String::new() })] #[error(transparent)]
MissingPython(PythonNotFound, Option<String>), MissingPython(#[from] PythonNotFound),
#[error(transparent)] #[error(transparent)]
MissingEnvironment(#[from] environment::EnvironmentNotFound), MissingEnvironment(#[from] environment::EnvironmentNotFound),
@ -104,21 +103,6 @@ pub enum Error {
InvalidEnvironment(#[from] environment::InvalidEnvironment), InvalidEnvironment(#[from] environment::InvalidEnvironment),
} }
impl Error {
pub(crate) fn with_missing_python_hint(self, hint: String) -> Self {
match self {
Error::MissingPython(err, _) => Error::MissingPython(err, Some(hint)),
_ => self,
}
}
}
impl From<PythonNotFound> for Error {
fn from(err: PythonNotFound) -> Self {
Error::MissingPython(err, None)
}
}
// The mock interpreters are not valid on Windows so we don't have unit test coverage there // The mock interpreters are not valid on Windows so we don't have unit test coverage there
// TODO(zanieb): We should write a mock interpreter script that works on Windows // TODO(zanieb): We should write a mock interpreter script that works on Windows
#[cfg(all(test, unix))] #[cfg(all(test, unix))]

View file

@ -362,7 +362,11 @@ impl ManagedPythonInstallation {
/// If windowed is true, `pythonw.exe` is selected over `python.exe` on windows, with no changes /// If windowed is true, `pythonw.exe` is selected over `python.exe` on windows, with no changes
/// on non-windows. /// on non-windows.
pub fn executable(&self, windowed: bool) -> PathBuf { pub fn executable(&self, windowed: bool) -> PathBuf {
let implementation = self.implementation().executable_name(); let implementation = match self.implementation() {
ImplementationName::CPython => "python",
ImplementationName::PyPy => "pypy",
ImplementationName::GraalPy => "graalpy",
};
let version = match self.implementation() { let version = match self.implementation() {
ImplementationName::CPython => { ImplementationName::CPython => {

View file

@ -43,36 +43,15 @@ impl Ord for Arch {
return self.variant.cmp(&other.variant); return self.variant.cmp(&other.variant);
} }
// For the time being, manually make aarch64 windows disfavored let native = Arch::from_env();
// on its own host platform, because most packages don't have wheels for
// aarch64 windows, making emulation more useful than native execution!
//
// The reason we do this in "sorting" and not "supports" is so that we don't
// *refuse* to use an aarch64 windows pythons if they happen to be installed
// and nothing else is available.
//
// Similarly if someone manually requests an aarch64 windows install, we
// should respect that request (this is the way users should "override"
// this behaviour).
let preferred = if cfg!(all(windows, target_arch = "aarch64")) {
Arch {
family: target_lexicon::Architecture::X86_64,
variant: None,
}
} else {
// Prefer native architectures
Arch::from_env()
};
match ( // Prefer native architectures
self.family == preferred.family, match (self.family == native.family, other.family == native.family) {
other.family == preferred.family,
) {
(true, true) => unreachable!(), (true, true) => unreachable!(),
(true, false) => std::cmp::Ordering::Less, (true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater, (false, true) => std::cmp::Ordering::Greater,
(false, false) => { (false, false) => {
// Both non-preferred, fallback to lexicographic order // Both non-native, fallback to lexicographic order
self.family.to_string().cmp(&other.family.to_string()) self.family.to_string().cmp(&other.family.to_string())
} }
} }

View file

@ -1,5 +1,3 @@
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::ops::Deref; use std::ops::Deref;
use std::str::FromStr; use std::str::FromStr;
@ -67,16 +65,26 @@ impl FromStr for PythonVersion {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for PythonVersion { impl schemars::JsonSchema for PythonVersion {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("PythonVersion") String::from("PythonVersion")
} }
fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ schemars::schema::SchemaObject {
"type": "string", instance_type: Some(schemars::schema::InstanceType::String.into()),
"pattern": r"^3\.\d+(\.\d+)?$", string: Some(Box::new(schemars::schema::StringValidation {
"description": "A Python version specifier, e.g. `3.11` or `3.12.4`." pattern: Some(r"^3\.\d+(\.\d+)?$".to_string()),
}) ..schemars::schema::StringValidation::default()
})),
metadata: Some(Box::new(schemars::schema::Metadata {
description: Some(
"A Python version specifier, e.g. `3.11` or `3.12.4`.".to_string(),
),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
} }
} }

View file

@ -1,7 +1,7 @@
//! DO NOT EDIT //! DO NOT EDIT
//! //!
//! Generated with `cargo run dev generate-sysconfig-metadata` //! Generated with `cargo run dev generate-sysconfig-metadata`
//! Targets from <https://github.com/astral-sh/python-build-standalone/blob/20250708/cpython-unix/targets.yml> //! Targets from <https://github.com/astral-sh/python-build-standalone/blob/20250612/cpython-unix/targets.yml>
//! //!
#![allow(clippy::all)] #![allow(clippy::all)]
#![cfg_attr(any(), rustfmt::skip)] #![cfg_attr(any(), rustfmt::skip)]
@ -15,6 +15,7 @@ use crate::sysconfig::replacements::{ReplacementEntry, ReplacementMode};
pub(crate) static DEFAULT_VARIABLE_UPDATES: LazyLock<BTreeMap<String, Vec<ReplacementEntry>>> = LazyLock::new(|| { pub(crate) static DEFAULT_VARIABLE_UPDATES: LazyLock<BTreeMap<String, Vec<ReplacementEntry>>> = LazyLock::new(|| {
BTreeMap::from_iter([ BTreeMap::from_iter([
("BLDSHARED".to_string(), vec![ ("BLDSHARED".to_string(), vec![
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/aarch64-linux-gnu-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-gcc".to_string() }, to: "cc".to_string() },
@ -27,6 +28,7 @@ pub(crate) static DEFAULT_VARIABLE_UPDATES: LazyLock<BTreeMap<String, Vec<Replac
ReplacementEntry { mode: ReplacementMode::Partial { from: "musl-clang".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "musl-clang".to_string() }, to: "cc".to_string() },
]), ]),
("CC".to_string(), vec![ ("CC".to_string(), vec![
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/aarch64-linux-gnu-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-gcc".to_string() }, to: "cc".to_string() },
@ -39,6 +41,7 @@ pub(crate) static DEFAULT_VARIABLE_UPDATES: LazyLock<BTreeMap<String, Vec<Replac
ReplacementEntry { mode: ReplacementMode::Partial { from: "musl-clang".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "musl-clang".to_string() }, to: "cc".to_string() },
]), ]),
("CXX".to_string(), vec![ ("CXX".to_string(), vec![
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/aarch64-linux-gnu-g++".to_string() }, to: "c++".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-g++".to_string() }, to: "c++".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-g++".to_string() }, to: "c++".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-g++".to_string() }, to: "c++".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-g++".to_string() }, to: "c++".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-g++".to_string() }, to: "c++".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-g++".to_string() }, to: "c++".to_string() },
@ -50,6 +53,7 @@ pub(crate) static DEFAULT_VARIABLE_UPDATES: LazyLock<BTreeMap<String, Vec<Replac
ReplacementEntry { mode: ReplacementMode::Partial { from: "clang++".to_string() }, to: "c++".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "clang++".to_string() }, to: "c++".to_string() },
]), ]),
("LDCXXSHARED".to_string(), vec![ ("LDCXXSHARED".to_string(), vec![
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/aarch64-linux-gnu-g++".to_string() }, to: "c++".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-g++".to_string() }, to: "c++".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-g++".to_string() }, to: "c++".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-g++".to_string() }, to: "c++".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-g++".to_string() }, to: "c++".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-g++".to_string() }, to: "c++".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-g++".to_string() }, to: "c++".to_string() },
@ -61,6 +65,7 @@ pub(crate) static DEFAULT_VARIABLE_UPDATES: LazyLock<BTreeMap<String, Vec<Replac
ReplacementEntry { mode: ReplacementMode::Partial { from: "clang++".to_string() }, to: "c++".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "clang++".to_string() }, to: "c++".to_string() },
]), ]),
("LDSHARED".to_string(), vec![ ("LDSHARED".to_string(), vec![
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/aarch64-linux-gnu-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-gcc".to_string() }, to: "cc".to_string() },
@ -73,6 +78,7 @@ pub(crate) static DEFAULT_VARIABLE_UPDATES: LazyLock<BTreeMap<String, Vec<Replac
ReplacementEntry { mode: ReplacementMode::Partial { from: "musl-clang".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "musl-clang".to_string() }, to: "cc".to_string() },
]), ]),
("LINKCC".to_string(), vec![ ("LINKCC".to_string(), vec![
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/aarch64-linux-gnu-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabi-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/arm-linux-gnueabihf-gcc".to_string() }, to: "cc".to_string() },
ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-gcc".to_string() }, to: "cc".to_string() }, ReplacementEntry { mode: ReplacementMode::Partial { from: "/usr/bin/mips-linux-gnu-gcc".to_string() }, to: "cc".to_string() },

View file

@ -349,7 +349,7 @@ mod tests {
// Cross-compiles use GNU // Cross-compiles use GNU
let sysconfigdata = [ let sysconfigdata = [
("CC", "/usr/bin/riscv64-linux-gnu-gcc"), ("CC", "/usr/bin/aarch64-linux-gnu-gcc"),
("CXX", "/usr/bin/x86_64-linux-gnu-g++"), ("CXX", "/usr/bin/x86_64-linux-gnu-g++"),
] ]
.into_iter() .into_iter()

View file

@ -177,9 +177,7 @@ impl FromStr for DisplaySafeUrl {
} }
fn is_ssh_git_username(url: &Url) -> bool { fn is_ssh_git_username(url: &Url) -> bool {
matches!(url.scheme(), "ssh" | "git+ssh" | "git+https") matches!(url.scheme(), "ssh" | "git+ssh") && url.username() == "git" && url.password().is_none()
&& url.username() == "git"
&& url.password().is_none()
} }
fn display_with_redacted_credentials( fn display_with_redacted_credentials(

View file

@ -1,7 +1,6 @@
--- ---
source: crates/uv-requirements-txt/src/lib.rs source: crates/uv-requirements-txt/src/lib.rs
expression: actual expression: actual
snapshot_kind: text
--- ---
RequirementsTxt { RequirementsTxt {
requirements: [ requirements: [
@ -24,7 +23,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4', marker: python_full_version >= '3.8' and python_full_version < '4.0',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
@ -55,7 +54,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4', marker: python_full_version >= '3.8' and python_full_version < '4.0',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
@ -86,7 +85,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4' and sys_platform == 'win32', marker: python_full_version >= '3.8' and python_full_version < '4.0' and sys_platform == 'win32',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
@ -117,7 +116,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4', marker: python_full_version >= '3.8' and python_full_version < '4.0',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
@ -149,7 +148,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4', marker: python_full_version >= '3.8' and python_full_version < '4.0',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",

View file

@ -1,7 +1,6 @@
--- ---
source: crates/uv-requirements-txt/src/lib.rs source: crates/uv-requirements-txt/src/lib.rs
expression: actual expression: actual
snapshot_kind: text
--- ---
RequirementsTxt { RequirementsTxt {
requirements: [ requirements: [
@ -24,7 +23,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4', marker: python_full_version >= '3.8' and python_full_version < '4.0',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
@ -55,7 +54,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4', marker: python_full_version >= '3.8' and python_full_version < '4.0',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
@ -86,7 +85,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4' and sys_platform == 'win32', marker: python_full_version >= '3.8' and python_full_version < '4.0' and sys_platform == 'win32',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
@ -117,7 +116,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4', marker: python_full_version >= '3.8' and python_full_version < '4.0',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",
@ -149,7 +148,7 @@ RequirementsTxt {
), ),
), ),
), ),
marker: python_full_version >= '3.8' and python_full_version < '4', marker: python_full_version >= '3.8' and python_full_version < '4.0',
origin: Some( origin: Some(
File( File(
"<REQUIREMENTS_DIR>/poetry-with-hashes.txt", "<REQUIREMENTS_DIR>/poetry-with-hashes.txt",

View file

@ -31,9 +31,6 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
WheelFilename(#[from] uv_distribution_filename::WheelFilenameError), WheelFilename(#[from] uv_distribution_filename::WheelFilenameError),
#[error("Failed to construct HTTP client")]
ClientError(#[source] anyhow::Error),
#[error(transparent)] #[error(transparent)]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }

View file

@ -13,9 +13,10 @@ use uv_normalize::PackageName;
use uv_pep440::Version; use uv_pep440::Version;
use uv_types::InstalledPackagesProvider; use uv_types::InstalledPackagesProvider;
use crate::preferences::{Entry, PreferenceSource, Preferences}; use crate::preferences::{Entry, Preferences};
use crate::prerelease::{AllowPrerelease, PrereleaseStrategy}; use crate::prerelease::{AllowPrerelease, PrereleaseStrategy};
use crate::resolution_mode::ResolutionStrategy; use crate::resolution_mode::ResolutionStrategy;
use crate::universal_marker::UniversalMarker;
use crate::version_map::{VersionMap, VersionMapDistHandle}; use crate::version_map::{VersionMap, VersionMapDistHandle};
use crate::{Exclusions, Manifest, Options, ResolverEnvironment}; use crate::{Exclusions, Manifest, Options, ResolverEnvironment};
@ -187,7 +188,7 @@ impl CandidateSelector {
if index.is_some_and(|index| !entry.index().matches(index)) { if index.is_some_and(|index| !entry.index().matches(index)) {
return None; return None;
} }
Either::Left(std::iter::once((entry.pin().version(), entry.source()))) Either::Left(std::iter::once((entry.marker(), entry.pin().version())))
} }
[..] => { [..] => {
type Entries<'a> = SmallVec<[&'a Entry; 3]>; type Entries<'a> = SmallVec<[&'a Entry; 3]>;
@ -218,7 +219,7 @@ impl CandidateSelector {
Either::Right( Either::Right(
preferences preferences
.into_iter() .into_iter()
.map(|entry| (entry.pin().version(), entry.source())), .map(|entry| (entry.marker(), entry.pin().version())),
) )
} }
}; };
@ -237,7 +238,7 @@ impl CandidateSelector {
/// Return the first preference that satisfies the current range and is allowed. /// Return the first preference that satisfies the current range and is allowed.
fn get_preferred_from_iter<'a, InstalledPackages: InstalledPackagesProvider>( fn get_preferred_from_iter<'a, InstalledPackages: InstalledPackagesProvider>(
&'a self, &'a self,
preferences: impl Iterator<Item = (&'a Version, PreferenceSource)>, preferences: impl Iterator<Item = (&'a UniversalMarker, &'a Version)>,
package_name: &'a PackageName, package_name: &'a PackageName,
range: &Range<Version>, range: &Range<Version>,
version_maps: &'a [VersionMap], version_maps: &'a [VersionMap],
@ -245,7 +246,7 @@ impl CandidateSelector {
reinstall: bool, reinstall: bool,
env: &ResolverEnvironment, env: &ResolverEnvironment,
) -> Option<Candidate<'a>> { ) -> Option<Candidate<'a>> {
for (version, source) in preferences { for (marker, version) in preferences {
// Respect the version range for this requirement. // Respect the version range for this requirement.
if !range.contains(version) { if !range.contains(version) {
continue; continue;
@ -289,14 +290,9 @@ impl CandidateSelector {
let allow = match self.prerelease_strategy.allows(package_name, env) { let allow = match self.prerelease_strategy.allows(package_name, env) {
AllowPrerelease::Yes => true, AllowPrerelease::Yes => true,
AllowPrerelease::No => false, AllowPrerelease::No => false,
// If the pre-release was provided via an existing file, rather than from the // If the pre-release is "global" (i.e., provided via a lockfile, rather than
// current solve, accept it unless pre-releases are completely banned. // a fork), accept it unless pre-releases are completely banned.
AllowPrerelease::IfNecessary => match source { AllowPrerelease::IfNecessary => marker.is_true(),
PreferenceSource::Resolver => false,
PreferenceSource::Lock
| PreferenceSource::Environment
| PreferenceSource::RequirementsTxt => true,
},
}; };
if !allow { if !allow {
continue; continue;

View file

@ -3,7 +3,6 @@ use std::fmt::Formatter;
use std::sync::Arc; use std::sync::Arc;
use indexmap::IndexSet; use indexmap::IndexSet;
use itertools::Itertools;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use pubgrub::{ use pubgrub::{
DefaultStringReporter, DerivationTree, Derived, External, Range, Ranges, Reporter, Term, DefaultStringReporter, DerivationTree, Derived, External, Range, Ranges, Reporter, Term,
@ -18,8 +17,6 @@ use uv_normalize::{ExtraName, InvalidNameError, PackageName};
use uv_pep440::{LocalVersionSlice, LowerBound, Version, VersionSpecifier}; use uv_pep440::{LocalVersionSlice, LowerBound, Version, VersionSpecifier};
use uv_pep508::{MarkerEnvironment, MarkerExpression, MarkerTree, MarkerValueVersion}; use uv_pep508::{MarkerEnvironment, MarkerExpression, MarkerTree, MarkerValueVersion};
use uv_platform_tags::Tags; use uv_platform_tags::Tags;
use uv_pypi_types::ParsedUrl;
use uv_redacted::DisplaySafeUrl;
use uv_static::EnvVars; use uv_static::EnvVars;
use crate::candidate_selector::CandidateSelector; use crate::candidate_selector::CandidateSelector;
@ -59,14 +56,11 @@ pub enum ResolveError {
} else { } else {
format!(" in {env}") format!(" in {env}")
}, },
urls.iter() urls.join("\n- "),
.map(|url| format!("{}{}", DisplaySafeUrl::from(url.clone()), if url.is_editable() { " (editable)" } else { "" }))
.collect::<Vec<_>>()
.join("\n- ")
)] )]
ConflictingUrls { ConflictingUrls {
package_name: PackageName, package_name: PackageName,
urls: Vec<ParsedUrl>, urls: Vec<String>,
env: ResolverEnvironment, env: ResolverEnvironment,
}, },
@ -77,14 +71,11 @@ pub enum ResolveError {
} else { } else {
format!(" in {env}") format!(" in {env}")
}, },
indexes.iter() indexes.join("\n- "),
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join("\n- ")
)] )]
ConflictingIndexesForEnvironment { ConflictingIndexesForEnvironment {
package_name: PackageName, package_name: PackageName,
indexes: Vec<IndexUrl>, indexes: Vec<String>,
env: ResolverEnvironment, env: ResolverEnvironment,
}, },
@ -157,7 +148,7 @@ impl<T> From<tokio::sync::mpsc::error::SendError<T>> for ResolveError {
} }
} }
pub type ErrorTree = DerivationTree<PubGrubPackage, Range<Version>, UnavailableReason>; pub(crate) type ErrorTree = DerivationTree<PubGrubPackage, Range<Version>, UnavailableReason>;
/// A wrapper around [`pubgrub::error::NoSolutionError`] that displays a resolution failure report. /// A wrapper around [`pubgrub::error::NoSolutionError`] that displays a resolution failure report.
pub struct NoSolutionError { pub struct NoSolutionError {
@ -368,11 +359,6 @@ impl NoSolutionError {
NoSolutionHeader::new(self.env.clone()) NoSolutionHeader::new(self.env.clone())
} }
/// Get the conflict derivation tree for external analysis
pub fn derivation_tree(&self) -> &ErrorTree {
&self.error
}
/// Hint at limiting the resolver environment if universal resolution failed for a target /// Hint at limiting the resolver environment if universal resolution failed for a target
/// that is not the current platform or not the current Python version. /// that is not the current platform or not the current Python version.
fn hint_disjoint_targets(&self, f: &mut Formatter) -> std::fmt::Result { fn hint_disjoint_targets(&self, f: &mut Formatter) -> std::fmt::Result {
@ -410,15 +396,6 @@ impl NoSolutionError {
} }
Ok(()) Ok(())
} }
/// Get the packages that are involved in this error.
pub fn packages(&self) -> impl Iterator<Item = &PackageName> {
self.error
.packages()
.into_iter()
.filter_map(|p| p.name())
.unique()
}
} }
impl std::fmt::Debug for NoSolutionError { impl std::fmt::Debug for NoSolutionError {
@ -1236,69 +1213,6 @@ impl SentinelRange<'_> {
} }
} }
/// A prefix match, e.g., `==2.4.*`, which is desugared to a range like `>=2.4.dev0,<2.5.dev0`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PrefixMatch<'a> {
version: &'a Version,
}
impl<'a> PrefixMatch<'a> {
/// Determine whether a given range is equivalent to a prefix match (e.g., `==2.4.*`).
///
/// Prefix matches are desugared to (e.g.) `>=2.4.dev0,<2.5.dev0`, but we want to render them
/// as `==2.4.*` in error messages.
pub(crate) fn from_range(lower: &'a Bound<Version>, upper: &'a Bound<Version>) -> Option<Self> {
let Bound::Included(lower) = lower else {
return None;
};
let Bound::Excluded(upper) = upper else {
return None;
};
if lower.is_pre() || lower.is_post() || lower.is_local() {
return None;
}
if upper.is_pre() || upper.is_post() || upper.is_local() {
return None;
}
if lower.dev() != Some(0) {
return None;
}
if upper.dev() != Some(0) {
return None;
}
if lower.release().len() != upper.release().len() {
return None;
}
// All segments should be the same, except the last one, which should be incremented.
let num_segments = lower.release().len();
for (i, (lower, upper)) in lower
.release()
.iter()
.zip(upper.release().iter())
.enumerate()
{
if i == num_segments - 1 {
if lower + 1 != *upper {
return None;
}
} else {
if lower != upper {
return None;
}
}
}
Some(PrefixMatch { version: lower })
}
}
impl std::fmt::Display for PrefixMatch<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "=={}.*", self.version.only_release())
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct NoSolutionHeader { pub struct NoSolutionHeader {
/// The [`ResolverEnvironment`] that caused the failure. /// The [`ResolverEnvironment`] that caused the failure.

View file

@ -1,5 +1,3 @@
#[cfg(feature = "schemars")]
use std::borrow::Cow;
use std::str::FromStr; use std::str::FromStr;
use jiff::{Timestamp, ToSpan, tz::TimeZone}; use jiff::{Timestamp, ToSpan, tz::TimeZone};
@ -69,15 +67,25 @@ impl std::fmt::Display for ExcludeNewer {
#[cfg(feature = "schemars")] #[cfg(feature = "schemars")]
impl schemars::JsonSchema for ExcludeNewer { impl schemars::JsonSchema for ExcludeNewer {
fn schema_name() -> Cow<'static, str> { fn schema_name() -> String {
Cow::Borrowed("ExcludeNewer") "ExcludeNewer".to_string()
} }
fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
schemars::json_schema!({ schemars::schema::SchemaObject {
"type": "string", instance_type: Some(schemars::schema::InstanceType::String.into()),
"pattern": r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2}))?$", string: Some(Box::new(schemars::schema::StringValidation {
"description": "Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`).", pattern: Some(
}) r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2}))?$".to_string(),
),
..schemars::schema::StringValidation::default()
})),
metadata: Some(Box::new(schemars::schema::Metadata {
description: Some("Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`).".to_string()),
..schemars::schema::Metadata::default()
})),
..schemars::schema::SchemaObject::default()
}
.into()
} }
} }

View file

@ -24,7 +24,7 @@ impl ForkIndexes {
) -> Result<(), ResolveError> { ) -> Result<(), ResolveError> {
if let Some(previous) = self.0.insert(package_name.clone(), index.clone()) { if let Some(previous) = self.0.insert(package_name.clone(), index.clone()) {
if &previous != index { if &previous != index {
let mut conflicts = vec![previous.url, index.url.clone()]; let mut conflicts = vec![previous.url.to_string(), index.url.to_string()];
conflicts.sort(); conflicts.sort();
return Err(ResolveError::ConflictingIndexesForEnvironment { return Err(ResolveError::ConflictingIndexesForEnvironment {
package_name: package_name.clone(), package_name: package_name.clone(),

View file

@ -2,6 +2,7 @@ use std::collections::hash_map::Entry;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use uv_distribution_types::Verbatim;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_pypi_types::VerbatimParsedUrl; use uv_pypi_types::VerbatimParsedUrl;
@ -33,8 +34,10 @@ impl ForkUrls {
match self.0.entry(package_name.clone()) { match self.0.entry(package_name.clone()) {
Entry::Occupied(previous) => { Entry::Occupied(previous) => {
if previous.get() != url { if previous.get() != url {
let mut conflicting_url = let mut conflicting_url = vec![
vec![previous.get().parsed_url.clone(), url.parsed_url.clone()]; previous.get().verbatim.verbatim().to_string(),
url.verbatim.verbatim().to_string(),
];
conflicting_url.sort(); conflicting_url.sort();
return Err(ResolveError::ConflictingUrls { return Err(ResolveError::ConflictingUrls {
package_name: package_name.clone(), package_name: package_name.clone(),

Some files were not shown because too many files have changed in this diff Show more