diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index b44a60332e022..ebbf0a22eb462 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -35,7 +35,7 @@ jobs: run: rustup show - name: "Install codspeed" - uses: taiki-e/install-action@fa0dd4cd0a40696e6f9766370614a5ce482e6aa8 # v2.77.5 + uses: taiki-e/install-action@213ccc1a076163c093f914550b94feb90fab916d # v2.79.2 with: tool: cargo-codspeed @@ -72,7 +72,7 @@ jobs: persist-credentials: false - name: "Install codspeed" - uses: taiki-e/install-action@fa0dd4cd0a40696e6f9766370614a5ce482e6aa8 # v2.77.5 + uses: taiki-e/install-action@213ccc1a076163c093f914550b94feb90fab916d # v2.79.2 with: tool: cargo-codspeed @@ -113,7 +113,7 @@ jobs: run: rustup show - name: "Install codspeed" - uses: taiki-e/install-action@fa0dd4cd0a40696e6f9766370614a5ce482e6aa8 # v2.77.5 + uses: taiki-e/install-action@213ccc1a076163c093f914550b94feb90fab916d # v2.79.2 with: tool: cargo-codspeed diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index ef257c1138c43..6cb93d42f3bd5 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -15,7 +15,7 @@ jobs: persist-credentials: false - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 diff --git a/.github/workflows/check-fmt.yml b/.github/workflows/check-fmt.yml index bdecd74f7b37e..9d4fe18f8be62 100644 --- a/.github/workflows/check-fmt.yml +++ b/.github/workflows/check-fmt.yml @@ -34,5 +34,5 @@ jobs: - name: "Install uv" uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - run: uvx ruff format --diff . diff --git a/.github/workflows/check-lint.yml b/.github/workflows/check-lint.yml index 398dc7951926b..4fbc71eb90997 100644 --- a/.github/workflows/check-lint.yml +++ b/.github/workflows/check-lint.yml @@ -28,7 +28,7 @@ jobs: - name: "Install uv" uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - run: uvx ruff check . ty: @@ -41,7 +41,7 @@ jobs: - name: "Install uv" uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - run: | uvx ty check python/uv uvx \ @@ -75,7 +75,7 @@ jobs: - name: "Install uv" uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - run: uvx --from 'validate-pyproject[all,store]' validate-pyproject pyproject.toml readme: @@ -103,7 +103,7 @@ jobs: with: save-if: ${{ inputs.save-rust-cache == 'true' }} - name: "Check uv_build dependencies" - uses: EmbarkStudios/cargo-deny-action@91bf2b620e09e18d6eb78b92e7861937469acedb # v2.0.17 + uses: EmbarkStudios/cargo-deny-action@6c8f9facfa5047ec02d8485b6bf52b587b7777d1 # v2.0.18 with: command: check bans manifest-path: crates/uv-build/Cargo.toml @@ -151,7 +151,7 @@ jobs: with: persist-credentials: false - name: "Install cargo shear" - uses: taiki-e/install-action@fa0dd4cd0a40696e6f9766370614a5ce482e6aa8 # v2.77.5 + uses: taiki-e/install-action@213ccc1a076163c093f914550b94feb90fab916d # v2.79.2 with: tool: cargo-shear@1.12.4 - run: cargo shear --deny-warnings @@ -162,4 +162,4 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef # v1.46.1 + - uses: crate-ci/typos@aca895bf05aec0cb7dffa6f94495e923224d9f17 # v1.46.2 diff --git a/.github/workflows/check-lock.yml b/.github/workflows/check-lock.yml index 9018f6fdcde2f..791a376b50ac6 100644 --- a/.github/workflows/check-lock.yml +++ b/.github/workflows/check-lock.yml @@ -15,7 +15,7 @@ jobs: - name: "Install uv" uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - name: "Check project lockfile" run: uv lock --check - name: "Check script lockfiles" diff --git a/.github/workflows/check-zizmor.yml b/.github/workflows/check-zizmor.yml index eef8fa85737b0..53739c6823e1e 100644 --- a/.github/workflows/check-zizmor.yml +++ b/.github/workflows/check-zizmor.yml @@ -13,4 +13,4 @@ jobs: with: persist-credentials: false - - uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3 + - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c13f38c3770..6d8e2ec18631f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: run-checks: ${{ steps.plan.outputs.run_checks }} test-publish: ${{ steps.plan.outputs.test_publish }} test-windows-trampoline: ${{ steps.plan.outputs.test_windows_trampoline }} + test-windows-trampoline-check-binary: ${{ steps.plan.outputs.test_windows_trampoline_check_binary }} save-rust-cache: ${{ steps.plan.outputs.save_rust_cache }} run-bench: ${{ steps.plan.outputs.run_bench }} test-smoke: ${{ steps.plan.outputs.test_smoke }} @@ -53,6 +54,7 @@ jobs: HAS_BUILD_SKIP_RELEASE_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'build:skip-release') }} HAS_BUILD_RELEASE_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'build:release') }} HAS_BUILD_PUSH_DOCKER_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'build:push-docker') }} + HAS_BUILD_WINDOWS_TRAMPOLINE_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'build:windows-trampoline') }} BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | [[ "$GH_REF" == "refs/heads/main" ]] && on_main_branch=1 @@ -67,6 +69,7 @@ jobs: [[ "$HAS_BUILD_SKIP_RELEASE_LABEL" == "true" ]] && has_build_skip_release_label=1 [[ "$HAS_BUILD_RELEASE_LABEL" == "true" ]] && has_build_release_label=1 [[ "$HAS_BUILD_PUSH_DOCKER_LABEL" == "true" ]] && has_build_push_docker_label=1 + [[ "$HAS_BUILD_WINDOWS_TRAMPOLINE_LABEL" == "true" ]] && has_build_windows_trampoline_label=1 # Detect changed files while IFS= read -r file; do @@ -83,7 +86,9 @@ jobs: [[ "$file" =~ ^crates/uv-publish/ || "$file" =~ ^scripts/publish/ || "$file" == "crates/uv/src/commands/publish.rs" ]] && publish_code_changed=1 [[ "$file" == ".github/workflows/test-windows-trampolines.yml" ]] && trampoline_workflow_changed=1 [[ "$file" =~ ^crates/uv-trampoline/ || "$file" =~ ^crates/uv-trampoline-builder/ ]] && trampoline_code_changed=1 - [[ "$file" == "scripts/build-trampolines.sh" || "$file" == "scripts/check-trampoline-version-consistency.py" ]] && trampoline_scripts_changed=1 + [[ "$file" =~ ^crates/uv-trampoline/.*\.rs$ || "$file" =~ ^crates/uv-trampoline-builder/trampolines/ ]] && trampoline_repro_changed=1 + [[ "$file" == "scripts/build-trampolines.sh" || "$file" == "scripts/build-trampolines-in-docker.sh" ]] && trampoline_build_scripts_changed=1 + [[ "$file" == "scripts/build-trampolines.sh" || "$file" == "scripts/build-trampolines-in-docker.sh" || "$file" == "scripts/check-trampoline-version-consistency.py" ]] && trampoline_scripts_changed=1 [[ "$file" =~ ^crates/uv-build/ ]] && uv_build_changed=1 [[ "$file" == "Dockerfile" ]] && dockerfile_changed=1 [[ "$file" == ".github/workflows/build-docker.yml" ]] && docker_workflow_changed=1 @@ -108,7 +113,8 @@ jobs: [[ ! $has_skip_label && ! $has_build_skip_label && ! $has_build_skip_release_label && ($release_build_changed || $has_build_release_label) ]] && build_release_binaries=1 [[ ! $has_skip_label ]] && run_checks=1 [[ $publish_changed || $has_publish_label || $has_extended_label || $on_main_branch ]] && test_publish=1 - [[ ! $has_skip_label && ($trampoline_code_changed || $trampoline_scripts_changed || $trampoline_workflow_changed || $rust_deps_changed || $on_main_branch) ]] && test_windows_trampoline=1 + [[ ! $has_skip_label && ($trampoline_code_changed || $trampoline_scripts_changed || $trampoline_workflow_changed || $rust_deps_changed || $has_build_windows_trampoline_label || $on_main_branch) ]] && test_windows_trampoline=1 + [[ ! $has_skip_label && ($trampoline_repro_changed || $trampoline_build_scripts_changed || $has_build_windows_trampoline_label) ]] && test_windows_trampoline_check_binary=1 [[ $on_main_branch || $cache_relevant_changed ]] && save_rust_cache=1 [[ ! $has_skip_label && ($any_rust_changed || $bench_workflow_changed || $on_main_branch) ]] && run_bench=1 [[ ! $has_skip_label ]] && test_smoke=1 @@ -128,6 +134,7 @@ jobs: out run_checks "$run_checks" out test_publish "$test_publish" out test_windows_trampoline "$test_windows_trampoline" + out test_windows_trampoline_check_binary "$test_windows_trampoline_check_binary" out save_rust_cache "$save_rust_cache" out run_bench "$run_bench" out test_smoke "$test_smoke" @@ -198,6 +205,8 @@ jobs: needs: plan if: ${{ needs.plan.outputs.test-windows-trampoline == 'true' }} uses: ./.github/workflows/test-windows-trampolines.yml + with: + trampoline-build: ${{ needs.plan.outputs.test-windows-trampoline-check-binary }} build-dev-binaries: needs: plan diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 80b7a85f6a671..9e284a8f0ce42 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -75,7 +75,7 @@ jobs: - name: "Install uv" uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - name: "Build docs" run: uv run --only-group docs mkdocs build --strict -f mkdocs.yml diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index b2675874870c5..75667c5830a9c 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -72,7 +72,7 @@ jobs: with: persist-credentials: false # TODO(zanieb): Find an alternative for this action; it uses EOL Node 20 - - uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3.3.0 + - uses: conda-incubator/setup-miniconda@8ee1f361103df19b6f8c8655fd3967a8ecb162d5 # v4.0.1 with: miniconda-version: latest activate-environment: uv @@ -105,7 +105,7 @@ jobs: with: persist-credentials: false # TODO(zanieb): Find an alternative for this action; it uses EOL Node 20 - - uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3.3.0 + - uses: conda-incubator/setup-miniconda@8ee1f361103df19b6f8c8655fd3967a8ecb162d5 # v4.0.1 with: miniconda-version: latest activate-environment: uv diff --git a/.github/workflows/test-system.yml b/.github/workflows/test-system.yml index 45efc32c3dd41..3fb6826344252 100644 --- a/.github/workflows/test-system.yml +++ b/.github/workflows/test-system.yml @@ -752,7 +752,7 @@ jobs: persist-credentials: false # TODO(zanieb): Find an alternative for this action; it uses EOL Node 20 - - uses: conda-incubator/setup-miniconda@fc2d68f6413eb2d87b895e92f8584b5b94a10167 # v3.3.0 + - uses: conda-incubator/setup-miniconda@8ee1f361103df19b6f8c8655fd3967a8ecb162d5 # v4.0.1 with: miniconda-version: "latest" activate-environment: uv diff --git a/.github/workflows/test-windows-trampolines.yml b/.github/workflows/test-windows-trampolines.yml index 6ec3d76d409b4..ba87a61bc7b3c 100644 --- a/.github/workflows/test-windows-trampolines.yml +++ b/.github/workflows/test-windows-trampolines.yml @@ -1,5 +1,11 @@ on: workflow_call: + inputs: + trampoline-build: + description: "Whether to check the committed trampoline binaries" + required: false + type: string + default: "false" permissions: {} @@ -25,15 +31,15 @@ jobs: - name: "Check windows crate versions match" run: uv run --no-project scripts/check-trampoline-version-consistency.py - # Verify committed trampolines match a reproducible Docker build - windows-trampoline-reproducible: - # uv version bumps (e.g., https://github.com/astral-sh/uv/pull/18805) cause the reproducible - # check to fail and these are not regenerated as a part of the release process so we temporarily - # disable this job - if: false + # Verify committed trampolines were built with the Docker image + windows-trampoline-check-binary: + # Only run when trampoline sources changed, the trampoline binaries changed + # or the `build:windows-trampoline` label is set. Dependency bumps would + # otherwise force a trampoline rebuild on every dependency update. + if: ${{ inputs.trampoline-build == 'true' }} timeout-minutes: 30 runs-on: ubuntu-latest - name: "check reproducible build" + name: "check binary" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -81,7 +87,7 @@ jobs: rustup component add rust-src --target ${{ matrix.target-arch }}-pc-windows-msvc - name: "Install cargo-bloat" - uses: taiki-e/install-action@fa0dd4cd0a40696e6f9766370614a5ce482e6aa8 # v2.77.5 + uses: taiki-e/install-action@213ccc1a076163c093f914550b94feb90fab916d # v2.79.2 with: tool: cargo-bloat diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f895d2c35efd4..dff0091d2cbfd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,7 +45,7 @@ jobs: - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - name: "Install required Python versions" run: uv python install @@ -85,7 +85,7 @@ jobs: sudo chown "$(id -u):$(id -g)" /minix - name: "Install cargo nextest" - uses: taiki-e/install-action@fa0dd4cd0a40696e6f9766370614a5ce482e6aa8 # v2.77.5 + uses: taiki-e/install-action@213ccc1a076163c093f914550b94feb90fab916d # v2.79.2 with: tool: cargo-nextest @@ -153,13 +153,13 @@ jobs: - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - name: "Install required Python versions" run: uv python install - name: "Install cargo nextest" - uses: taiki-e/install-action@fa0dd4cd0a40696e6f9766370614a5ce482e6aa8 # v2.77.5 + uses: taiki-e/install-action@213ccc1a076163c093f914550b94feb90fab916d # v2.79.2 with: tool: cargo-nextest @@ -226,7 +226,7 @@ jobs: - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - version: "0.11.14" + version: "0.11.16" - name: "Install required Python versions" run: uv python install @@ -251,7 +251,7 @@ jobs: New-SmbShare -Name "uv-alt-fs" -Path "C:\uv-smb" -FullAccess "Everyone" - name: "Install cargo nextest" - uses: taiki-e/install-action@fa0dd4cd0a40696e6f9766370614a5ce482e6aa8 # v2.77.5 + uses: taiki-e/install-action@213ccc1a076163c093f914550b94feb90fab916d # v2.79.2 with: tool: cargo-nextest diff --git a/CHANGELOG.md b/CHANGELOG.md index 6146d73611791..6b2668ef594c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,43 @@ +## 0.11.17 + +Released on 2026-05-28. + +### Enhancements + +- Add a diagnostic for `uv add` with standard library modules ([#19572](https://github.com/astral-sh/uv/pull/19572)) +- Expose `uv workspace` and its `list` subcommand in help output ([#19533](https://github.com/astral-sh/uv/pull/19533)) +- Improve the "403 forbidden" hint to suggest `ignore-error-codes` when applicable ([#19521](https://github.com/astral-sh/uv/pull/19521)) +- Skip direct URL lock freshness checks while offline ([#19596](https://github.com/astral-sh/uv/pull/19596)) +- Add `import-names` and `import-namespaces` support to `uv-build` ([PEP 794](https://peps.python.org/pep-0794/)) ([#19380](https://github.com/astral-sh/uv/pull/19380)) +- Add a `--no-editable-package` flag to various commands ([#19584](https://github.com/astral-sh/uv/pull/19584)) +- Infer Python version requests from source trees in `uv tool` invocations ([#19577](https://github.com/astral-sh/uv/pull/19577)) + +### Preview features + +- Add module owners to `uv workspace metadata` ([#19122](https://github.com/astral-sh/uv/pull/19122)) +- Do not allow `uv venv --clear` to remove non-virtual environments ([#19595](https://github.com/astral-sh/uv/pull/19595)) + +### Bug fixes + +- Improve the performance of large entries in `tool.uv.conflicts` ([#19538](https://github.com/astral-sh/uv/pull/19538)) +- Avoid modifying the parent process' env with `--env-file` in `uv run` ([#19567](https://github.com/astral-sh/uv/pull/19567)) +- Fix script environment creation for scripts with long filenames ([#19539](https://github.com/astral-sh/uv/pull/19539)) +- Fix transitive Git archive dependencies in lockfiles ([#19589](https://github.com/astral-sh/uv/pull/19589)) +- Preserve Git repository URLs in direct URL metadata ([#19590](https://github.com/astral-sh/uv/pull/19590)) +- Support redirects in `--check-url` ([#19594](https://github.com/astral-sh/uv/pull/19594)) +- Accept case-insensitive HTML tags in `--find-links` parsing ([#19537](https://github.com/astral-sh/uv/pull/19537)) +- Reject duplicate script metadata blocks ([#19544](https://github.com/astral-sh/uv/pull/19544)) +- Ban names like "python3" as script entry points ([#19535](https://github.com/astral-sh/uv/pull/19535), [#19536](https://github.com/astral-sh/uv/pull/19536)) +- Validate Git LFS artifacts for Git archives ([#19592](https://github.com/astral-sh/uv/pull/19592)) +- Use a relative path when creating symlinks in cache to improve relocatability ([#19033](https://github.com/astral-sh/uv/pull/19033)) + +### Documentation + +- Fix malformed positional anchors in the CLI reference ([#19575](https://github.com/astral-sh/uv/pull/19575)) + ## 0.11.16 Released on 2026-05-21. @@ -930,3 +967,4 @@ See [changelogs/0.1.x](./changelogs/0.1.x.md) + diff --git a/Cargo.lock b/Cargo.lock index 650806f3eca4a..0de71ff19a8f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,9 +188,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", @@ -517,6 +517,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -1179,9 +1188,9 @@ dependencies = [ [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1536,9 +1545,9 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.28" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", @@ -3012,9 +3021,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.3.4" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "is-wsl", "libc", @@ -3677,9 +3686,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.7" +version = "0.14.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" dependencies = [ "pem", "ring", @@ -5652,7 +5661,7 @@ dependencies = [ [[package]] name = "uv" -version = "0.11.16" +version = "0.11.17" dependencies = [ "anstream", "anyhow", @@ -5778,7 +5787,7 @@ dependencies = [ [[package]] name = "uv-audit" -version = "0.0.49" +version = "0.0.50" dependencies = [ "astral-reqwest-middleware", "clap", @@ -5809,7 +5818,7 @@ dependencies = [ [[package]] name = "uv-auth" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "arcstr", @@ -5852,7 +5861,7 @@ dependencies = [ [[package]] name = "uv-bench" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "codspeed-criterion-compat", @@ -5879,7 +5888,7 @@ dependencies = [ [[package]] name = "uv-bin-install" -version = "0.0.49" +version = "0.0.50" dependencies = [ "astral-reqwest-middleware", "astral-reqwest-retry", @@ -5907,7 +5916,7 @@ dependencies = [ [[package]] name = "uv-build" -version = "0.11.16" +version = "0.11.17" dependencies = [ "anstream", "anyhow", @@ -5922,7 +5931,7 @@ dependencies = [ [[package]] name = "uv-build-backend" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anstream", "astral-tokio-tar", @@ -5970,7 +5979,7 @@ dependencies = [ [[package]] name = "uv-build-frontend" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anstream", "fs-err", @@ -6008,7 +6017,7 @@ dependencies = [ [[package]] name = "uv-cache" -version = "0.0.49" +version = "0.0.50" dependencies = [ "clap", "fs-err", @@ -6034,7 +6043,7 @@ dependencies = [ [[package]] name = "uv-cache-info" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "fs-err", @@ -6051,7 +6060,7 @@ dependencies = [ [[package]] name = "uv-cache-key" -version = "0.0.49" +version = "0.0.50" dependencies = [ "hex", "memchr", @@ -6063,7 +6072,7 @@ dependencies = [ [[package]] name = "uv-cli" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anstream", "anyhow", @@ -6096,7 +6105,7 @@ dependencies = [ [[package]] name = "uv-client" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "astral-reqwest-middleware", @@ -6167,7 +6176,7 @@ dependencies = [ [[package]] name = "uv-configuration" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "clap", @@ -6201,23 +6210,23 @@ dependencies = [ [[package]] name = "uv-console" -version = "0.0.49" +version = "0.0.50" dependencies = [ "console", ] [[package]] name = "uv-dev" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anstream", "anyhow", "clap", "fs-err", "futures", + "insta", "itertools 0.14.0", "markdown", - "owo-colors", "poloto", "pretty_assertions", "reqwest", @@ -6240,6 +6249,7 @@ dependencies = [ "uv-configuration", "uv-distribution-filename", "uv-distribution-types", + "uv-errors", "uv-extract", "uv-git", "uv-installer", @@ -6258,7 +6268,7 @@ dependencies = [ [[package]] name = "uv-dirs" -version = "0.0.49" +version = "0.0.50" dependencies = [ "assert_fs", "etcetera", @@ -6270,7 +6280,7 @@ dependencies = [ [[package]] name = "uv-dispatch" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "futures", @@ -6304,7 +6314,7 @@ dependencies = [ [[package]] name = "uv-distribution" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "astral-reqwest-middleware", @@ -6356,7 +6366,7 @@ dependencies = [ [[package]] name = "uv-distribution-filename" -version = "0.0.49" +version = "0.0.50" dependencies = [ "insta", "memchr", @@ -6373,7 +6383,7 @@ dependencies = [ [[package]] name = "uv-distribution-types" -version = "0.0.49" +version = "0.0.50" dependencies = [ "arcstr", "astral-version-ranges", @@ -6414,14 +6424,22 @@ dependencies = [ [[package]] name = "uv-errors" -version = "0.0.49" +version = "0.0.50" dependencies = [ + "anstream", + "anyhow", + "indoc", + "insta", "owo-colors", + "terminal_size", + "textwrap", + "thiserror", + "uv-static", ] [[package]] name = "uv-extract" -version = "0.0.49" +version = "0.0.50" dependencies = [ "astral-tokio-tar", "astral_async_zip", @@ -6450,7 +6468,7 @@ dependencies = [ [[package]] name = "uv-fastid" -version = "0.0.49" +version = "0.0.50" dependencies = [ "fastrand", "rand 0.9.4", @@ -6460,14 +6478,14 @@ dependencies = [ [[package]] name = "uv-flags" -version = "0.0.49" +version = "0.0.50" dependencies = [ "bitflags", ] [[package]] name = "uv-fs" -version = "0.0.49" +version = "0.0.50" dependencies = [ "backon", "clap", @@ -6498,7 +6516,7 @@ dependencies = [ [[package]] name = "uv-git" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "astral-reqwest-middleware", @@ -6524,7 +6542,7 @@ dependencies = [ [[package]] name = "uv-git-types" -version = "0.0.49" +version = "0.0.50" dependencies = [ "percent-encoding", "serde", @@ -6538,7 +6556,7 @@ dependencies = [ [[package]] name = "uv-globfilter" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anstream", "fs-err", @@ -6555,7 +6573,7 @@ dependencies = [ [[package]] name = "uv-install-wheel" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "assert_fs", @@ -6592,7 +6610,7 @@ dependencies = [ [[package]] name = "uv-installer" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anstream", "anyhow", @@ -6636,7 +6654,7 @@ dependencies = [ [[package]] name = "uv-keyring" -version = "0.0.49" +version = "0.0.50" dependencies = [ "async-trait", "byteorder", @@ -6652,7 +6670,7 @@ dependencies = [ [[package]] name = "uv-logging" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anstream", "jiff", @@ -6663,7 +6681,7 @@ dependencies = [ [[package]] name = "uv-macros" -version = "0.0.49" +version = "0.0.50" dependencies = [ "proc-macro2", "quote", @@ -6673,7 +6691,7 @@ dependencies = [ [[package]] name = "uv-metadata" -version = "0.0.49" +version = "0.0.50" dependencies = [ "astral_async_zip", "fs-err", @@ -6689,7 +6707,7 @@ dependencies = [ [[package]] name = "uv-netrc" -version = "0.0.49" +version = "0.0.50" dependencies = [ "fs-err", "shellexpand", @@ -6699,7 +6717,7 @@ dependencies = [ [[package]] name = "uv-normalize" -version = "0.0.49" +version = "0.0.50" dependencies = [ "rkyv", "schemars", @@ -6709,7 +6727,7 @@ dependencies = [ [[package]] name = "uv-once-map" -version = "0.0.49" +version = "0.0.50" dependencies = [ "dashmap", "futures", @@ -6718,14 +6736,14 @@ dependencies = [ [[package]] name = "uv-options-metadata" -version = "0.0.49" +version = "0.0.50" dependencies = [ "serde", ] [[package]] name = "uv-pep440" -version = "0.0.49" +version = "0.0.50" dependencies = [ "astral-version-ranges", "indoc", @@ -6739,7 +6757,7 @@ dependencies = [ [[package]] name = "uv-pep508" -version = "0.0.49" +version = "0.0.50" dependencies = [ "arcstr", "astral-version-ranges", @@ -6769,7 +6787,7 @@ dependencies = [ [[package]] name = "uv-performance-memory-allocator" -version = "0.0.49" +version = "0.0.50" dependencies = [ "mimalloc", "tikv-jemallocator", @@ -6777,7 +6795,7 @@ dependencies = [ [[package]] name = "uv-platform" -version = "0.0.49" +version = "0.0.50" dependencies = [ "fs-err", "goblin", @@ -6798,7 +6816,7 @@ dependencies = [ [[package]] name = "uv-platform-tags" -version = "0.0.49" +version = "0.0.50" dependencies = [ "bitflags", "insta", @@ -6812,7 +6830,7 @@ dependencies = [ [[package]] name = "uv-preview" -version = "0.0.49" +version = "0.0.50" dependencies = [ "enumflags2", "itertools 0.14.0", @@ -6823,7 +6841,7 @@ dependencies = [ [[package]] name = "uv-publish" -version = "0.0.49" +version = "0.0.50" dependencies = [ "ambient-id", "anstream", @@ -6853,6 +6871,7 @@ dependencies = [ "uv-configuration", "uv-distribution-filename", "uv-distribution-types", + "uv-errors", "uv-extract", "uv-fs", "uv-metadata", @@ -6865,7 +6884,7 @@ dependencies = [ [[package]] name = "uv-pypi-types" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "hashbrown 0.17.1", @@ -6898,7 +6917,7 @@ dependencies = [ [[package]] name = "uv-python" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "assert_fs", @@ -6961,7 +6980,7 @@ dependencies = [ [[package]] name = "uv-redacted" -version = "0.0.49" +version = "0.0.50" dependencies = [ "ref-cast", "schemars", @@ -6972,7 +6991,7 @@ dependencies = [ [[package]] name = "uv-requirements" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "configparser", @@ -7006,7 +7025,7 @@ dependencies = [ [[package]] name = "uv-requirements-txt" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "assert_fs", @@ -7039,7 +7058,7 @@ dependencies = [ [[package]] name = "uv-resolver" -version = "0.0.49" +version = "0.0.50" dependencies = [ "arcstr", "astral-pubgrub", @@ -7106,7 +7125,7 @@ dependencies = [ [[package]] name = "uv-scripts" -version = "0.0.49" +version = "0.0.50" dependencies = [ "fs-err", "indoc", @@ -7131,7 +7150,7 @@ dependencies = [ [[package]] name = "uv-settings" -version = "0.0.49" +version = "0.0.50" dependencies = [ "clap", "fs-err", @@ -7168,7 +7187,7 @@ dependencies = [ [[package]] name = "uv-shell" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "fs-err", @@ -7185,7 +7204,7 @@ dependencies = [ [[package]] name = "uv-small-str" -version = "0.0.49" +version = "0.0.50" dependencies = [ "arcstr", "rkyv", @@ -7195,7 +7214,7 @@ dependencies = [ [[package]] name = "uv-state" -version = "0.0.49" +version = "0.0.50" dependencies = [ "tempfile", "uv-dirs", @@ -7203,7 +7222,7 @@ dependencies = [ [[package]] name = "uv-static" -version = "0.0.49" +version = "0.0.50" dependencies = [ "thiserror", "uv-macros", @@ -7211,7 +7230,7 @@ dependencies = [ [[package]] name = "uv-test" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "assert_cmd", @@ -7241,7 +7260,7 @@ dependencies = [ [[package]] name = "uv-toml" -version = "0.0.49" +version = "0.0.50" dependencies = [ "toml_datetime", "toml_parser", @@ -7249,7 +7268,7 @@ dependencies = [ [[package]] name = "uv-tool" -version = "0.0.49" +version = "0.0.50" dependencies = [ "fs-err", "owo-colors", @@ -7278,7 +7297,7 @@ dependencies = [ [[package]] name = "uv-torch" -version = "0.0.49" +version = "0.0.50" dependencies = [ "clap", "either", @@ -7298,7 +7317,7 @@ dependencies = [ [[package]] name = "uv-trampoline-builder" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "assert_cmd", @@ -7317,7 +7336,7 @@ dependencies = [ [[package]] name = "uv-types" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "dashmap", @@ -7340,7 +7359,7 @@ dependencies = [ [[package]] name = "uv-unix" -version = "0.0.49" +version = "0.0.50" dependencies = [ "nix 0.31.2", "thiserror", @@ -7348,11 +7367,11 @@ dependencies = [ [[package]] name = "uv-version" -version = "0.11.16" +version = "0.11.17" [[package]] name = "uv-virtualenv" -version = "0.0.49" +version = "0.0.50" dependencies = [ "console", "fs-err", @@ -7369,23 +7388,24 @@ dependencies = [ "uv-python", "uv-shell", "uv-version", + "uv-warnings", ] [[package]] name = "uv-warnings" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anstream", "anyhow", - "indoc", "insta", "owo-colors", "rustc-hash", + "uv-errors", ] [[package]] name = "uv-windows" -version = "0.0.49" +version = "0.0.50" dependencies = [ "arrayvec", "windows", @@ -7393,7 +7413,7 @@ dependencies = [ [[package]] name = "uv-workspace" -version = "0.0.49" +version = "0.0.50" dependencies = [ "anyhow", "assert_fs", @@ -8160,10 +8180,11 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yasna" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ + "bit-vec", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 9dd8be79ec6f2..a5d7d7a5aff59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,81 +16,81 @@ authors = ["uv"] license = "MIT OR Apache-2.0" [workspace.dependencies] -uv-audit = { version = "0.0.49", path = "crates/uv-audit" } -uv-auth = { version = "0.0.49", path = "crates/uv-auth" } -uv-bin-install = { version = "0.0.49", path = "crates/uv-bin-install" } -uv-build-backend = { version = "0.0.49", path = "crates/uv-build-backend" } -uv-build-frontend = { version = "0.0.49", path = "crates/uv-build-frontend" } -uv-cache = { version = "0.0.49", path = "crates/uv-cache" } -uv-cache-info = { version = "0.0.49", path = "crates/uv-cache-info" } -uv-cache-key = { version = "0.0.49", path = "crates/uv-cache-key" } -uv-cli = { version = "0.0.49", path = "crates/uv-cli" } -uv-client = { version = "0.0.49", path = "crates/uv-client" } -uv-configuration = { version = "0.0.49", path = "crates/uv-configuration" } -uv-console = { version = "0.0.49", path = "crates/uv-console" } -uv-dirs = { version = "0.0.49", path = "crates/uv-dirs" } -uv-dispatch = { version = "0.0.49", path = "crates/uv-dispatch" } -uv-distribution = { version = "0.0.49", path = "crates/uv-distribution" } -uv-distribution-filename = { version = "0.0.49", path = "crates/uv-distribution-filename" } -uv-distribution-types = { version = "0.0.49", path = "crates/uv-distribution-types" } -uv-errors = { version = "0.0.49", path = "crates/uv-errors" } -uv-extract = { version = "0.0.49", path = "crates/uv-extract" } -uv-fastid = { version = "0.0.49", path = "crates/uv-fastid" } -uv-flags = { version = "0.0.49", path = "crates/uv-flags" } -uv-fs = { version = "0.0.49", path = "crates/uv-fs", features = [ +uv-audit = { version = "0.0.50", path = "crates/uv-audit" } +uv-auth = { version = "0.0.50", path = "crates/uv-auth" } +uv-bin-install = { version = "0.0.50", path = "crates/uv-bin-install" } +uv-build-backend = { version = "0.0.50", path = "crates/uv-build-backend" } +uv-build-frontend = { version = "0.0.50", path = "crates/uv-build-frontend" } +uv-cache = { version = "0.0.50", path = "crates/uv-cache" } +uv-cache-info = { version = "0.0.50", path = "crates/uv-cache-info" } +uv-cache-key = { version = "0.0.50", path = "crates/uv-cache-key" } +uv-cli = { version = "0.0.50", path = "crates/uv-cli" } +uv-client = { version = "0.0.50", path = "crates/uv-client" } +uv-configuration = { version = "0.0.50", path = "crates/uv-configuration" } +uv-console = { version = "0.0.50", path = "crates/uv-console" } +uv-dirs = { version = "0.0.50", path = "crates/uv-dirs" } +uv-dispatch = { version = "0.0.50", path = "crates/uv-dispatch" } +uv-distribution = { version = "0.0.50", path = "crates/uv-distribution" } +uv-distribution-filename = { version = "0.0.50", path = "crates/uv-distribution-filename" } +uv-distribution-types = { version = "0.0.50", path = "crates/uv-distribution-types" } +uv-errors = { version = "0.0.50", path = "crates/uv-errors" } +uv-extract = { version = "0.0.50", path = "crates/uv-extract" } +uv-fastid = { version = "0.0.50", path = "crates/uv-fastid" } +uv-flags = { version = "0.0.50", path = "crates/uv-flags" } +uv-fs = { version = "0.0.50", path = "crates/uv-fs", features = [ "serde", "tokio", ] } -uv-git = { version = "0.0.49", path = "crates/uv-git" } -uv-git-types = { version = "0.0.49", path = "crates/uv-git-types" } -uv-globfilter = { version = "0.0.49", path = "crates/uv-globfilter" } -uv-install-wheel = { version = "0.0.49", path = "crates/uv-install-wheel", default-features = false } -uv-installer = { version = "0.0.49", path = "crates/uv-installer" } -uv-keyring = { version = "0.0.49", path = "crates/uv-keyring" } -uv-logging = { version = "0.0.49", path = "crates/uv-logging" } -uv-macros = { version = "0.0.49", path = "crates/uv-macros" } -uv-metadata = { version = "0.0.49", path = "crates/uv-metadata" } -uv-netrc = { version = "0.0.49", path = "crates/uv-netrc" } -uv-normalize = { version = "0.0.49", path = "crates/uv-normalize" } -uv-once-map = { version = "0.0.49", path = "crates/uv-once-map" } -uv-options-metadata = { version = "0.0.49", path = "crates/uv-options-metadata" } -uv-performance-memory-allocator = { version = "0.0.49", path = "crates/uv-performance-memory-allocator" } -uv-pep440 = { version = "0.0.49", path = "crates/uv-pep440", features = [ +uv-git = { version = "0.0.50", path = "crates/uv-git" } +uv-git-types = { version = "0.0.50", path = "crates/uv-git-types" } +uv-globfilter = { version = "0.0.50", path = "crates/uv-globfilter" } +uv-install-wheel = { version = "0.0.50", path = "crates/uv-install-wheel", default-features = false } +uv-installer = { version = "0.0.50", path = "crates/uv-installer" } +uv-keyring = { version = "0.0.50", path = "crates/uv-keyring" } +uv-logging = { version = "0.0.50", path = "crates/uv-logging" } +uv-macros = { version = "0.0.50", path = "crates/uv-macros" } +uv-metadata = { version = "0.0.50", path = "crates/uv-metadata" } +uv-netrc = { version = "0.0.50", path = "crates/uv-netrc" } +uv-normalize = { version = "0.0.50", path = "crates/uv-normalize" } +uv-once-map = { version = "0.0.50", path = "crates/uv-once-map" } +uv-options-metadata = { version = "0.0.50", path = "crates/uv-options-metadata" } +uv-performance-memory-allocator = { version = "0.0.50", path = "crates/uv-performance-memory-allocator" } +uv-pep440 = { version = "0.0.50", path = "crates/uv-pep440", features = [ "tracing", "rkyv", "version-ranges", ] } -uv-pep508 = { version = "0.0.49", path = "crates/uv-pep508", features = [ +uv-pep508 = { version = "0.0.50", path = "crates/uv-pep508", features = [ "non-pep508-extensions", ] } -uv-platform = { version = "0.0.49", path = "crates/uv-platform" } -uv-platform-tags = { version = "0.0.49", path = "crates/uv-platform-tags" } -uv-preview = { version = "0.0.49", path = "crates/uv-preview" } -uv-publish = { version = "0.0.49", path = "crates/uv-publish" } -uv-pypi-types = { version = "0.0.49", path = "crates/uv-pypi-types" } -uv-python = { version = "0.0.49", path = "crates/uv-python" } -uv-redacted = { version = "0.0.49", path = "crates/uv-redacted" } -uv-requirements = { version = "0.0.49", path = "crates/uv-requirements" } -uv-requirements-txt = { version = "0.0.49", path = "crates/uv-requirements-txt" } -uv-resolver = { version = "0.0.49", path = "crates/uv-resolver" } -uv-scripts = { version = "0.0.49", path = "crates/uv-scripts" } -uv-settings = { version = "0.0.49", path = "crates/uv-settings" } -uv-shell = { version = "0.0.49", path = "crates/uv-shell" } -uv-small-str = { version = "0.0.49", path = "crates/uv-small-str" } -uv-state = { version = "0.0.49", path = "crates/uv-state" } -uv-static = { version = "0.0.49", path = "crates/uv-static" } -uv-test = { version = "0.0.49", path = "crates/uv-test" } -uv-toml = { version = "0.0.49", path = "crates/uv-toml" } -uv-tool = { version = "0.0.49", path = "crates/uv-tool" } -uv-torch = { version = "0.0.49", path = "crates/uv-torch" } -uv-trampoline-builder = { version = "0.0.49", path = "crates/uv-trampoline-builder" } -uv-types = { version = "0.0.49", path = "crates/uv-types" } -uv-unix = { version = "0.0.49", path = "crates/uv-unix" } -uv-version = { version = "0.11.16", path = "crates/uv-version" } -uv-virtualenv = { version = "0.0.49", path = "crates/uv-virtualenv" } -uv-warnings = { version = "0.0.49", path = "crates/uv-warnings" } -uv-windows = { version = "0.0.49", path = "crates/uv-windows" } -uv-workspace = { version = "0.0.49", path = "crates/uv-workspace" } +uv-platform = { version = "0.0.50", path = "crates/uv-platform" } +uv-platform-tags = { version = "0.0.50", path = "crates/uv-platform-tags" } +uv-preview = { version = "0.0.50", path = "crates/uv-preview" } +uv-publish = { version = "0.0.50", path = "crates/uv-publish" } +uv-pypi-types = { version = "0.0.50", path = "crates/uv-pypi-types" } +uv-python = { version = "0.0.50", path = "crates/uv-python" } +uv-redacted = { version = "0.0.50", path = "crates/uv-redacted" } +uv-requirements = { version = "0.0.50", path = "crates/uv-requirements" } +uv-requirements-txt = { version = "0.0.50", path = "crates/uv-requirements-txt" } +uv-resolver = { version = "0.0.50", path = "crates/uv-resolver" } +uv-scripts = { version = "0.0.50", path = "crates/uv-scripts" } +uv-settings = { version = "0.0.50", path = "crates/uv-settings" } +uv-shell = { version = "0.0.50", path = "crates/uv-shell" } +uv-small-str = { version = "0.0.50", path = "crates/uv-small-str" } +uv-state = { version = "0.0.50", path = "crates/uv-state" } +uv-static = { version = "0.0.50", path = "crates/uv-static" } +uv-test = { version = "0.0.50", path = "crates/uv-test" } +uv-toml = { version = "0.0.50", path = "crates/uv-toml" } +uv-tool = { version = "0.0.50", path = "crates/uv-tool" } +uv-torch = { version = "0.0.50", path = "crates/uv-torch" } +uv-trampoline-builder = { version = "0.0.50", path = "crates/uv-trampoline-builder" } +uv-types = { version = "0.0.50", path = "crates/uv-types" } +uv-unix = { version = "0.0.50", path = "crates/uv-unix" } +uv-version = { version = "0.11.17", path = "crates/uv-version" } +uv-virtualenv = { version = "0.0.50", path = "crates/uv-virtualenv" } +uv-warnings = { version = "0.0.50", path = "crates/uv-warnings" } +uv-windows = { version = "0.0.50", path = "crates/uv-windows" } +uv-workspace = { version = "0.0.50", path = "crates/uv-workspace" } ambient-id = { version = "0.0.11", default-features = false, features = [ "reqwest-middleware", @@ -260,6 +260,7 @@ spdx = { version = "0.13.0" } syn = { version = "2.0.77" } target-lexicon = { version = "0.13.0" } tempfile = { version = "3.14.0" } +terminal_size = { version = "0.4.2" } textwrap = { version = "0.16.1" } thiserror = { version = "2.0.0" } astral-tl = { version = "0.7.11" } diff --git a/clippy.toml b/clippy.toml index 8171ad154cbcb..2a018c7cd877e 100644 --- a/clippy.toml +++ b/clippy.toml @@ -47,6 +47,18 @@ disallowed-types = [ ] disallowed-methods = [ + "dotenvy::dotenv", + "dotenvy::dotenv_override", + "dotenvy::from_filename", + "dotenvy::from_filename_override", + "dotenvy::from_path", + "dotenvy::from_path_override", + "dotenvy::from_read", + "dotenvy::from_read_override", + "dotenvy::var", + "dotenvy::vars", + "dotenvy::Iter::load", + "dotenvy::Iter::load_override", "std::fs::canonicalize", "std::fs::copy", "std::fs::create_dir", diff --git a/crates/uv-audit/Cargo.toml b/crates/uv-audit/Cargo.toml index bfda773a444a5..88ae5dc6f6386 100644 --- a/crates/uv-audit/Cargo.toml +++ b/crates/uv-audit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-audit" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition.workspace = true rust-version.workspace = true diff --git a/crates/uv-audit/README.md b/crates/uv-audit/README.md index b0286caf6a3aa..4bd7a70e117bf 100644 --- a/crates/uv-audit/README.md +++ b/crates/uv-audit/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-audit). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-audit). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-auth/Cargo.toml b/crates/uv-auth/Cargo.toml index cdd85e73001f0..ebeadf295517e 100644 --- a/crates/uv-auth/Cargo.toml +++ b/crates/uv-auth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-auth" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-auth/README.md b/crates/uv-auth/README.md index f88c28f90d68f..ea71da11d10c7 100644 --- a/crates/uv-auth/README.md +++ b/crates/uv-auth/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-auth). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-auth). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-bench/Cargo.toml b/crates/uv-bench/Cargo.toml index 6e56f82d8d6d9..cd4f4f3862f7f 100644 --- a/crates/uv-bench/Cargo.toml +++ b/crates/uv-bench/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-bench" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" publish = false authors = { workspace = true } diff --git a/crates/uv-bench/README.md b/crates/uv-bench/README.md index 0ecc252ffb06c..c43ee10b02e23 100644 --- a/crates/uv-bench/README.md +++ b/crates/uv-bench/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-bench). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-bench). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-bin-install/Cargo.toml b/crates/uv-bin-install/Cargo.toml index 32ddfb9ed54af..bb711381aeda1 100644 --- a/crates/uv-bin-install/Cargo.toml +++ b/crates/uv-bin-install/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-bin-install" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-bin-install/README.md b/crates/uv-bin-install/README.md index 4a4799b5b5ac1..1567728b1d6e6 100644 --- a/crates/uv-bin-install/README.md +++ b/crates/uv-bin-install/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-bin-install). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-bin-install). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-build-backend/Cargo.toml b/crates/uv-build-backend/Cargo.toml index 65ba2dc4d30ee..82c61e8f331d0 100644 --- a/crates/uv-build-backend/Cargo.toml +++ b/crates/uv-build-backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-build-backend" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-build-backend/README.md b/crates/uv-build-backend/README.md index 0c676ff984d70..43b55e5ed423d 100644 --- a/crates/uv-build-backend/README.md +++ b/crates/uv-build-backend/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-build-backend). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-build-backend). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-build-backend/src/metadata.rs b/crates/uv-build-backend/src/metadata.rs index 5221016dda8e1..ab09d4ad06651 100644 --- a/crates/uv-build-backend/src/metadata.rs +++ b/crates/uv-build-backend/src/metadata.rs @@ -2,8 +2,9 @@ use indexmap::IndexMap; use itertools::Itertools; use serde::{Deserialize, Deserializer}; use std::borrow::Cow; -use std::collections::{BTreeMap, Bound}; +use std::collections::{BTreeMap, BTreeSet, Bound}; use std::ffi::OsStr; +use std::fmt::Display; use std::fmt::Write; use std::path::{Path, PathBuf}; use std::str::{self, FromStr}; @@ -19,7 +20,9 @@ use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::{ ExtraOperator, MarkerExpression, MarkerTree, MarkerValueExtra, Requirement, VersionOrUrl, }; -use uv_pypi_types::{Keywords, Metadata23, ProjectUrls, VerbatimParsedUrl}; +use uv_pypi_types::{ + Identifier, IdentifierParseError, Keywords, Metadata23, ProjectUrls, VerbatimParsedUrl, +}; use crate::serde_verbatim::SerdeVerbatim; use crate::{BuildBackendSettings, Error, error_on_venv}; @@ -75,6 +78,27 @@ pub enum ValidationError { LicenseFileNotUtf8(String), #[error("`project.classifiers` contains an invalid classifier: {0}")] InvalidClassifiers(String), + #[error("`project.import-namespaces` must not be empty")] + EmptyImportNamespaces, + #[error("`{field}` entry must not be empty")] + EmptyImportName { field: &'static str }, + #[error("`{field}` entry `{value}` must end with `; private` or nothing, found `{suffix}`")] + InvalidImportSuffix { + field: &'static str, + value: String, + suffix: String, + }, + #[error("`{field}` entry `{value}` has an invalid import name")] + InvalidImportName { + field: &'static str, + value: String, + #[source] + source: IdentifierParseError, + }, + #[error( + "`project.import-names` and `project.import-namespaces` must not both contain `{name}`" + )] + DuplicateImportAcross { name: String }, } /// The project is not compatible with a direct uv build. @@ -97,6 +121,109 @@ pub enum DirectBuildIncompatibility { IncompatibleRange(VersionSpecifiers), } +#[derive(Debug, Clone)] +struct ImportEntry { + base: String, + is_private: bool, +} + +impl ImportEntry { + /// The special case where `import-names = []` in `pyproject.toml` is converted to an empty + /// `Import-Name` field. + fn empty() -> Self { + Self { + base: String::new(), + is_private: false, + } + } +} + +impl Display for ImportEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.base.is_empty() { + Ok(()) + } else if self.is_private { + write!(f, "{}; private", self.base) + } else { + write!(f, "{}", self.base) + } + } +} + +fn parse_import_names( + import_names: Option<&[String]>, +) -> Result>, ValidationError> { + let Some(import_names) = import_names else { + return Ok(None); + }; + if import_names.is_empty() { + return Ok(Some(vec![ImportEntry::empty()])); + } + + let import_names = import_names + .iter() + .map(|value| parse_import_entry(value, "project.import-names")) + .collect::, _>>()?; + Ok(Some(import_names)) +} + +fn parse_import_namespaces( + import_namespaces: Option<&[String]>, +) -> Result>, ValidationError> { + let Some(import_namespaces) = import_namespaces else { + return Ok(None); + }; + if import_namespaces.is_empty() { + return Err(ValidationError::EmptyImportNamespaces); + } + + let import_namespaces = import_namespaces + .iter() + .map(|value| parse_import_entry(value, "project.import-namespaces")) + .collect::, _>>()?; + Ok(Some(import_namespaces)) +} + +/// Parse `Import-Name` and `Import-Namespace` entries. +/// +/// An import name(space) is a module path, consisting of identifiers with dots in between, and +/// an optional `; private` suffix, e.g., `tqdm` or `foo.bar.baz ; private`. +fn parse_import_entry(value: &str, field: &'static str) -> Result { + let (name_part, suffix_part) = match value.split_once(';') { + Some((name, suffix)) => (name.trim_end(), Some(suffix.trim())), + None => (value, None), + }; + + if name_part.is_empty() { + return Err(ValidationError::EmptyImportName { field }); + } + + let is_private = match suffix_part { + Some("private") => true, + Some(suffix) => { + return Err(ValidationError::InvalidImportSuffix { + field, + value: value.to_string(), + suffix: suffix.to_string(), + }); + } + None => false, + }; + + for segment in name_part.split('.') { + Identifier::from_str(segment).map_err(|source| ValidationError::InvalidImportName { + field, + value: name_part.to_string(), + source, + })?; + } + + Ok(ImportEntry { + base: name_part.to_string(), + is_private, + }) +} + /// Check if the build backend is matching the currently running uv version. /// /// Example table compatible with uv 0.4.21: @@ -359,6 +486,25 @@ impl PyProjectToml { return Err(ValidationError::Dynamic.into()); } + let import_names = parse_import_names(self.project.import_names.as_deref())?; + let import_namespaces = parse_import_namespaces(self.project.import_namespaces.as_deref())?; + + if let (Some(import_names), Some(import_namespaces)) = (&import_names, &import_namespaces) { + let import_name_set: BTreeSet<_> = import_names + .iter() + .map(|entry| entry.base.as_str()) + .collect(); + if let Some(overlap) = import_namespaces + .iter() + .find(|entry| import_name_set.contains(entry.base.as_str())) + { + return Err(ValidationError::DuplicateImportAcross { + name: overlap.base.clone(), + } + .into()); + } + } + let author = self .project .authors @@ -421,9 +567,13 @@ impl PyProjectToml { .filter(|maintainer_email| !maintainer_email.is_empty()); // Using PEP 639 bumps the METADATA version - let metadata_version = if self.project.license_files.is_some() - || matches!(self.project.license, Some(License::Spdx(_))) - { + let uses_pep639 = self.project.license_files.is_some() + || matches!(self.project.license, Some(License::Spdx(_))); + let uses_pep794 = import_names.is_some() || import_namespaces.is_some(); + let metadata_version = if uses_pep794 { + debug!("Found import name metadata declarations, using METADATA 2.5"); + "2.5" + } else if uses_pep639 { debug!("Found PEP 639 license declarations, using METADATA 2.4"); "2.4" } else { @@ -467,6 +617,23 @@ impl PyProjectToml { )) .collect::>(); + let import_names = import_names + .map(|import_names| { + import_names + .into_iter() + .map(|entry| entry.to_string()) + .collect() + }) + .unwrap_or_default(); + let import_namespaces = import_namespaces + .map(|import_namespaces| { + import_namespaces + .into_iter() + .map(|entry| entry.to_string()) + .collect() + }) + .unwrap_or_default(); + Ok(Metadata23 { metadata_version: metadata_version.to_string(), name: self.project.name.given.clone(), @@ -496,7 +663,6 @@ impl PyProjectToml { .cloned() .map(Into::into) .collect(), - requires_dist: requires_dist.iter().map(ToString::to_string).collect(), provides_extra: extras.iter().map(ToString::to_string).collect(), // Not commonly set. @@ -512,6 +678,8 @@ impl PyProjectToml { requires_external: vec![], project_urls, dynamic: vec![], + import_names, + import_namespaces, }) } @@ -817,6 +985,14 @@ struct Project { dependencies: Option>, /// The optional dependencies of the project. optional_dependencies: Option>>, + /// Import names exclusively provided by the project. + /// + /// From PEP 794. + import_names: Option>, + /// Import namespaces provided by the project. + /// + /// From PEP 794. + import_namespaces: Option>, /// Specifies which fields listed by PEP 621 were intentionally unspecified so another tool /// can/will provide such metadata dynamically. /// @@ -1234,6 +1410,114 @@ mod tests { "); } + #[test] + fn import_names_metadata() { + let temp_dir = TempDir::new().unwrap(); + let contents = extend_project(indoc! {r#" + import-names = ["spam", "spam.eggs ; private", "mod.tools.cli"] + import-namespaces = ["mod", "mod.tools"] + "#}); + let pyproject_toml: PyProjectToml = toml::from_str(&contents).unwrap(); + let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap(); + + assert_snapshot!(metadata.core_metadata_format(), @" + Metadata-Version: 2.5 + Name: hello-world + Version: 0.1.0 + Import-Name: spam + Import-Name: spam.eggs; private + Import-Name: mod.tools.cli + Import-Namespace: mod + Import-Namespace: mod.tools + "); + } + + #[test] + fn import_names_empty_list() { + let temp_dir = TempDir::new().unwrap(); + let contents = extend_project(indoc! {r" + import-names = [] + "}); + let pyproject_toml: PyProjectToml = toml::from_str(&contents).unwrap(); + let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap(); + + assert_snapshot!(metadata.core_metadata_format(), @" + Metadata-Version: 2.5 + Name: hello-world + Version: 0.1.0 + Import-Name: + "); + } + + #[test] + fn import_names_empty_string() { + let temp_dir = TempDir::new().unwrap(); + let contents = extend_project(indoc! {r#" + import-names = [""] + "#}); + let pyproject_toml: PyProjectToml = toml::from_str(&contents).unwrap(); + let err = pyproject_toml + .to_metadata(temp_dir.path()) + .map(|_| ()) + .unwrap_err(); + assert_snapshot!(format_err(err), @" + Invalid project metadata + Caused by: `project.import-names` entry must not be empty + "); + } + + #[test] + fn import_namespaces_empty_string() { + let temp_dir = TempDir::new().unwrap(); + let contents = extend_project(indoc! {r#" + import-namespaces = [""] + "#}); + let pyproject_toml: PyProjectToml = toml::from_str(&contents).unwrap(); + let err = pyproject_toml + .to_metadata(temp_dir.path()) + .map(|_| ()) + .unwrap_err(); + assert_snapshot!(format_err(err), @" + Invalid project metadata + Caused by: `project.import-namespaces` entry must not be empty + "); + } + + #[test] + fn import_names_dotted_without_prefix() { + let temp_dir = TempDir::new().unwrap(); + let contents = extend_project(indoc! {r#" + import-names = ["spam.eggs"] + "#}); + let pyproject_toml: PyProjectToml = toml::from_str(&contents).unwrap(); + let metadata = pyproject_toml.to_metadata(temp_dir.path()).unwrap(); + + assert_snapshot!(metadata.core_metadata_format(), @" + Metadata-Version: 2.5 + Name: hello-world + Version: 0.1.0 + Import-Name: spam.eggs + "); + } + + #[test] + fn import_names_overlap() { + let temp_dir = TempDir::new().unwrap(); + let contents = extend_project(indoc! {r#" + import-names = ["spam"] + import-namespaces = ["spam"] + "#}); + let pyproject_toml: PyProjectToml = toml::from_str(&contents).unwrap(); + let err = pyproject_toml + .to_metadata(temp_dir.path()) + .map(|_| ()) + .unwrap_err(); + assert_snapshot!(format_err(err), @" + Invalid project metadata + Caused by: `project.import-names` and `project.import-namespaces` must not both contain `spam` + "); + } + #[test] fn readme() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/uv-build-frontend/Cargo.toml b/crates/uv-build-frontend/Cargo.toml index b7ec47baf82ed..23c94c6a9c205 100644 --- a/crates/uv-build-frontend/Cargo.toml +++ b/crates/uv-build-frontend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-build-frontend" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-build-frontend/README.md b/crates/uv-build-frontend/README.md index 80c25206e6b6d..7962fbfac50d6 100644 --- a/crates/uv-build-frontend/README.md +++ b/crates/uv-build-frontend/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-build-frontend). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-build-frontend). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-build/Cargo.toml b/crates/uv-build/Cargo.toml index fd76798baed81..bebfaee522a91 100644 --- a/crates/uv-build/Cargo.toml +++ b/crates/uv-build/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-build" -version = "0.11.16" +version = "0.11.17" description = "A Python build backend" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-build/pyproject.toml b/crates/uv-build/pyproject.toml index b15c7e755947c..351660c6954fa 100644 --- a/crates/uv-build/pyproject.toml +++ b/crates/uv-build/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uv-build" -version = "0.11.16" +version = "0.11.17" description = "The uv build backend" authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] requires-python = ">=3.8" diff --git a/crates/uv-cache-info/Cargo.toml b/crates/uv-cache-info/Cargo.toml index 5a0d0109b54d9..f0254ac6c5f0c 100644 --- a/crates/uv-cache-info/Cargo.toml +++ b/crates/uv-cache-info/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-cache-info" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-cache-info/README.md b/crates/uv-cache-info/README.md index 608b6049dde02..feaebdd07a461 100644 --- a/crates/uv-cache-info/README.md +++ b/crates/uv-cache-info/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-cache-info). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-cache-info). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-cache-key/Cargo.toml b/crates/uv-cache-key/Cargo.toml index ab88ad46cdedd..dc69e7d1da95d 100644 --- a/crates/uv-cache-key/Cargo.toml +++ b/crates/uv-cache-key/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-cache-key" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-cache-key/README.md b/crates/uv-cache-key/README.md index c6891a11d9f52..d09ea51d3f42a 100644 --- a/crates/uv-cache-key/README.md +++ b/crates/uv-cache-key/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-cache-key). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-cache-key). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-cache/Cargo.toml b/crates/uv-cache/Cargo.toml index 8e8675bb62c70..35d6367bc1dda 100644 --- a/crates/uv-cache/Cargo.toml +++ b/crates/uv-cache/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-cache" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-cache/README.md b/crates/uv-cache/README.md index 2c9b4b2e3aef8..4e46d1110df07 100644 --- a/crates/uv-cache/README.md +++ b/crates/uv-cache/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-cache). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-cache). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index f40a883e937f2..b207f55cc3f8e 100644 --- a/crates/uv-cache/src/lib.rs +++ b/crates/uv-cache/src/lib.rs @@ -826,16 +826,17 @@ impl Cache { /// version. On Unix, we create a symlink to the target directory. #[cfg(unix)] fn create_link(&self, id: &ArchiveId, dst: impl AsRef) -> io::Result<()> { - // Construct the link target. - let src = self.archive(id); let dst = dst.as_ref(); + let dst_parent = dst.parent().expect("Cache entry to have parent"); + // Construct the relative link target. + let src = uv_fs::relative_to(self.archive(id), dst_parent)?; // Attempt to create the symlink directly. match fs_err::os::unix::fs::symlink(&src, dst) { Ok(()) => Ok(()), Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { // Create a symlink, using a temporary file to ensure atomicity. - let temp_dir = tempfile::tempdir_in(dst.parent().unwrap())?; + let temp_dir = tempfile::tempdir_in(dst_parent)?; let temp_file = temp_dir.path().join("link"); fs_err::os::unix::fs::symlink(&src, &temp_file)?; diff --git a/crates/uv-cli/Cargo.toml b/crates/uv-cli/Cargo.toml index 90eff417ef089..2b3aad9ba81c8 100644 --- a/crates/uv-cli/Cargo.toml +++ b/crates/uv-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-cli" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-cli/README.md b/crates/uv-cli/README.md index 15d73edf28165..7f7f2304619fe 100644 --- a/crates/uv-cli/README.md +++ b/crates/uv-cli/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-cli). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-cli). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 5a8c5cc183ac7..85c928ebe320f 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -541,8 +541,7 @@ pub enum Commands { /// Inspect uv workspaces. #[command( after_help = "Use `uv help workspace` for more details.", - after_long_help = "", - hide = true + after_long_help = "" )] Workspace(WorkspaceNamespace), /// The implementation of the build backend. @@ -2124,6 +2123,10 @@ pub struct PipInstallArgs { #[arg(long, value_parser = clap::builder::BoolishValueParser::new())] pub no_editable: bool, + /// Install the specified editable packages as non-editable. + #[arg(long, value_delimiter = ' ', value_hint = ValueHint::Other)] + pub no_editable_package: Vec, + /// Constrain versions using the given requirements files. /// /// Constraints files are `requirements.txt`-like files that only control the _version_ of a @@ -3151,6 +3154,12 @@ pub struct VenvArgs { #[clap(long, short, overrides_with = "allow_existing", value_parser = clap::builder::BoolishValueParser::new())] pub clear: bool, + /// Allow `--clear` to remove a non-virtual environment directory. + /// + /// This will remove all files and directories at the target path. + #[arg(long)] + pub force: bool, + /// Fail without prompting if any existing files or directories are present at the target path. /// /// By default, when a TTY is available, `uv venv` will prompt to clear a non-empty directory. @@ -3619,6 +3628,10 @@ pub struct RunArgs { #[arg(long, overrides_with = "editable", value_parser = clap::builder::BoolishValueParser::new())] pub no_editable: bool, + /// Install the specified editable packages as non-editable. + #[arg(long, value_delimiter = ' ', value_hint = ValueHint::Other)] + pub no_editable_package: Vec, + /// Do not remove extraneous packages present in the environment. #[arg(long, overrides_with("exact"), alias = "no-exact", hide = true)] pub inexact: bool, @@ -3949,6 +3962,10 @@ pub struct SyncArgs { #[arg(long, overrides_with = "editable", value_parser = clap::builder::BoolishValueParser::new())] pub no_editable: bool, + /// Install the specified editable packages as non-editable. + #[arg(long, value_delimiter = ' ', value_hint = ValueHint::Other)] + pub no_editable_package: Vec, + /// Do not remove extraneous packages present in the environment. /// /// When enabled, uv will make the minimum necessary changes to satisfy the requirements. @@ -4327,6 +4344,10 @@ pub struct AddArgs { #[arg(long, overrides_with = "editable", hide = true, value_parser = clap::builder::BoolishValueParser::new())] pub no_editable: bool, + /// Don't add the specified requirements as editable. + #[arg(long, value_delimiter = ' ', value_hint = ValueHint::Other, hide = true)] + pub no_editable_package: Vec, + /// Add a dependency as provided. /// /// By default, uv will use the `tool.uv.sources` section to record source information for Git, @@ -4954,6 +4975,10 @@ pub struct ExportArgs { #[arg(long, overrides_with = "editable", value_parser = clap::builder::BoolishValueParser::new())] pub no_editable: bool, + /// Export the specified editable packages as non-editable. + #[arg(long, value_delimiter = ' ', value_hint = ValueHint::Other)] + pub no_editable_package: Vec, + /// Include hashes for all dependencies. #[arg(long, overrides_with("no_hashes"), hide = true)] pub hashes: bool, @@ -8011,7 +8036,6 @@ pub enum WorkspaceCommand { /// List the members of a workspace. /// /// Displays newline separated names of workspace members. - #[command(hide = true)] List(WorkspaceListArgs), } #[derive(Args)] @@ -8031,7 +8055,12 @@ pub struct MetadataArgs { /// /// In dry-run mode, uv will resolve the project's dependencies and report on the resulting /// changes, but will not write the lockfile to disk. - #[arg(long, conflicts_with = "frozen", conflicts_with = "locked")] + #[arg( + long, + conflicts_with = "frozen", + conflicts_with = "locked", + conflicts_with = "sync" + )] pub dry_run: bool, #[command(flatten)] @@ -8043,6 +8072,13 @@ pub struct MetadataArgs { #[command(flatten)] pub refresh: RefreshArgs, + /// Sync the environment to include module ownership metadata in the output. + /// + /// This adds a mapping from importable module names to references to the package nodes + /// that provide them. To do this, the venv will be synced in inexact mode. + #[arg(long)] + pub sync: bool, + /// The Python interpreter to use during resolution. /// /// A Python interpreter is required for building source distributions to determine package diff --git a/crates/uv-client/Cargo.toml b/crates/uv-client/Cargo.toml index d843ddca70143..e369f95d14ed5 100644 --- a/crates/uv-client/Cargo.toml +++ b/crates/uv-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-client" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-client/README.md b/crates/uv-client/README.md index 57398024aae5e..9afc18bb159d1 100644 --- a/crates/uv-client/README.md +++ b/crates/uv-client/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-client). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-client). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index ed93afa702cbf..834ec4ba0caf2 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -24,6 +24,7 @@ use url::Url; use uv_auth::{AuthMiddleware, Credentials, CredentialsCache, Indexes, PyxTokenStore}; use uv_configuration::ProxyUrlKind; use uv_configuration::{KeyringProviderType, ProxyUrl, TrustedHost}; +use uv_git::GitHttpSettings; use uv_pep508::MarkerEnvironment; use uv_platform_tags::Platform; use uv_preview::Preview; @@ -720,6 +721,13 @@ impl BaseClient { .any(|allow_insecure_host| allow_insecure_host.matches(url)) } + /// Return the [`GitHttpSettings`] for fetching from the given URL. + pub fn git_http_settings(&self, url: &DisplaySafeUrl) -> GitHttpSettings { + GitHttpSettings::default() + .with_disabled_ssl(self.disable_ssl(url)) + .with_offline(self.connectivity().is_offline()) + } + /// The configured client read timeout. pub fn read_timeout(&self) -> Duration { self.read_timeout diff --git a/crates/uv-client/src/error.rs b/crates/uv-client/src/error.rs index cd5556a2ce9da..58f0feff6edb5 100644 --- a/crates/uv-client/src/error.rs +++ b/crates/uv-client/src/error.rs @@ -14,6 +14,7 @@ use crate::{FlatIndexError, html}; use uv_cache::Error as CacheError; use uv_distribution_filename::{WheelFilename, WheelFilenameError}; use uv_distribution_types::IndexUrl; +use uv_git::GitError; use uv_normalize::PackageName; use uv_redacted::DisplaySafeUrl; @@ -385,6 +386,9 @@ pub enum ErrorKind { #[error(transparent)] Git(#[from] uv_git::GitResolverError), + #[error("The wheel `{0}` is missing Git LFS artifacts.")] + MissingWheelGitLfsArtifacts(DisplaySafeUrl, #[source] GitError), + #[error("Expected a file URL, but received: {0}")] NonFileUrl(DisplaySafeUrl), diff --git a/crates/uv-client/src/html.rs b/crates/uv-client/src/html.rs index f39e24ed2bdd5..b15cc40e515c9 100644 --- a/crates/uv-client/src/html.rs +++ b/crates/uv-client/src/html.rs @@ -1,4 +1,4 @@ -use std::str::FromStr; +use std::{borrow::Cow, str::FromStr}; use jiff::Timestamp; use tl::{HTMLTag, Node, Parser}; @@ -11,6 +11,36 @@ use uv_pypi_types::{HashError, LenientVersionSpecifiers}; use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError}; use uv_small_str::SmallString; +/// Return `true` if this tag has the given HTML element name. +fn is_tag(tag: &HTMLTag<'_>, name: &[u8]) -> bool { + tag.name().as_bytes().eq_ignore_ascii_case(name) +} + +/// Return the value of the attribute with the given case-insensitive HTML attribute name. +fn attribute<'a>(tag: &'a HTMLTag<'_>, name: &'a str) -> Option> { + tag.attributes() + .get(name) + .flatten() + .map(tl::Bytes::as_utf8_str) + .or_else(|| { + tag.attributes().iter().find_map(|(attribute_name, value)| { + attribute_name + .eq_ignore_ascii_case(name) + .then_some(value) + .flatten() + }) + }) +} + +/// Return `true` if the tag has the given case-insensitive HTML attribute name. +fn has_attribute<'a>(tag: &'a HTMLTag<'_>, name: &'a str) -> bool { + tag.attributes().contains(name) + || tag + .attributes() + .iter() + .any(|(attribute_name, _)| attribute_name.eq_ignore_ascii_case(name)) +} + /// A parsed structure from PyPI "HTML" index format for a single package. #[derive(Debug, Clone)] pub(crate) struct SimpleDetailHTML { @@ -36,13 +66,8 @@ impl SimpleDetailHTML { let project_status = dom .nodes() .iter() - .find(|node| { - node.as_tag() - .is_some_and(|tag| tag.name().as_bytes() == b"head") - }) - .map(|head| Self::parse_project_status(dom.parser(), head)) - .transpose()? - .flatten() + .find(|node| node.as_tag().is_some_and(|tag| is_tag(tag, b"head"))) + .and_then(|head| Self::parse_project_status(dom.parser(), head)) .unwrap_or_default(); // Parse the first `` tag, if any, to determine the base URL to which all @@ -52,8 +77,8 @@ impl SimpleDetailHTML { dom.nodes() .iter() .filter_map(|node| node.as_tag()) - .take_while(|tag| !matches!(tag.name().as_bytes(), b"a" | b"link")) - .find(|tag| tag.name().as_bytes() == b"base") + .take_while(|tag| !is_tag(tag, b"a") && !is_tag(tag, b"link")) + .find(|tag| is_tag(tag, b"base")) .map(|base| Self::parse_base(base)) .transpose()? .flatten() @@ -65,7 +90,7 @@ impl SimpleDetailHTML { .nodes() .iter() .filter_map(|node| node.as_tag()) - .filter(|link| link.name().as_bytes() == b"a") + .filter(|link| is_tag(link, b"a")) .map(|link| Self::parse_anchor(link)) .filter_map(|result| match result { Ok(None) => None, @@ -93,55 +118,38 @@ impl SimpleDetailHTML { /// Parse a [`ProjectStatus`] from the `` tags in the given ``. /// /// Precondition: `head` is a `` tag. - fn parse_project_status(parser: &Parser, head: &Node) -> Result, Error> { - /// Extract the value of the `content` attribute from a tag. - fn content<'a>(tag: &'a HTMLTag<'a>) -> Result, Error> { - let Some(content) = tag - .attributes() - .get("content") - .and_then(|bytes| bytes) - .map(|bytes| std::str::from_utf8(bytes.as_bytes())) - .transpose()? - else { - return Ok(None); - }; - Ok(Some(content)) - } - - let Some(children) = head.children() else { - return Ok(None); - }; + fn parse_project_status(parser: &Parser, head: &Node) -> Option { + let children = head.children()?; let mut status: Option = None; let mut reason: Option = None; for node in children.all(parser) { let tag = match node.as_tag() { - Some(tag) if tag.name().as_bytes() == b"meta" => tag, + Some(tag) if is_tag(tag, b"meta") => tag, _ => continue, }; - let name = match tag.attributes().get("name").and_then(|bytes| bytes) { - Some(name) => std::str::from_utf8(name.as_bytes())?, - None => continue, + let Some(name) = attribute(tag, "name") else { + continue; }; // Per PEP 792: both `pypi:project-status` and `pypi:project-status-reason` // are optional, but if present should be well-formed. - match name { + match name.as_ref() { "pypi:project-status" => { status = { - let Some(status) = content(tag)?.and_then(Status::new) else { - return Ok(None); - }; + let status = attribute(tag, "content").as_deref().and_then(Status::new)?; Some(status) }; } "pypi:project-status-reason" => { reason = { - let Some(content) = content(tag)?.map(SmallString::from) else { + let Some(content) = + attribute(tag, "content").as_deref().map(SmallString::from) + else { // TODO: Make this a hard error instead? warn!("Invalid project status reason (missing)"); - return Ok(None); + return None; }; Some(content) } @@ -152,20 +160,19 @@ impl SimpleDetailHTML { if let Some(status) = status { let status = ProjectStatus { status, reason }; - Ok(Some(status)) + Some(status) } else { - Ok(None) + None } } /// Parse the `href` from a `` tag. fn parse_base(base: &HTMLTag) -> Result, Error> { - let Some(Some(href)) = base.attributes().get("href") else { + let Some(href) = attribute(base, "href") else { return Ok(None); }; - let href = std::str::from_utf8(href.as_bytes())?; let url = - DisplaySafeUrl::parse(href).map_err(|err| Error::UrlParse(href.to_string(), err))?; + DisplaySafeUrl::parse(&href).map_err(|err| Error::UrlParse(href.to_string(), err))?; Ok(Some(url)) } @@ -174,18 +181,12 @@ impl SimpleDetailHTML { /// Returns `None` if the `` doesn't have an `href` attribute. fn parse_anchor(link: &HTMLTag) -> Result, Error> { // Extract the href. - let Some(href) = link - .attributes() - .get("href") - .flatten() - .filter(|bytes| !bytes.as_bytes().is_empty()) - else { + let Some(href) = attribute(link, "href").filter(|href| !href.is_empty()) else { return Ok(None); }; - let href = std::str::from_utf8(href.as_bytes())?; // Extract the hash, which should be in the fragment. - let decoded = html_escape::decode_html_entities(href); + let decoded = html_escape::decode_html_entities(&href); let (path, hashes) = if let Some((path, fragment)) = decoded.split_once('#') { let fragment = percent_encoding::percent_decode_str(fragment).decode_utf8()?; ( @@ -237,8 +238,7 @@ impl SimpleDetailHTML { // Extract the `requires-python` value, which should be set on the // `data-requires-python` attribute. - let requires_python = if let Some(requires_python) = - link.attributes().get("data-requires-python").flatten() + let requires_python = if let Some(requires_python) = attribute(link, "data-requires-python") { let requires_python = std::str::from_utf8(requires_python.as_bytes())?; let requires_python = html_escape::decode_html_entities(requires_python); @@ -250,11 +250,8 @@ impl SimpleDetailHTML { // Extract the `core-metadata` field, which is either set on: // - `data-core-metadata`, per PEP 714. // - `data-dist-info-metadata`, per PEP 658. - let core_metadata = if let Some(dist_info_metadata) = link - .attributes() - .get("data-core-metadata") - .flatten() - .or_else(|| link.attributes().get("data-dist-info-metadata").flatten()) + let core_metadata = if let Some(dist_info_metadata) = attribute(link, "data-core-metadata") + .or_else(|| attribute(link, "data-dist-info-metadata")) { let dist_info_metadata = std::str::from_utf8(dist_info_metadata.as_bytes())?; let dist_info_metadata = html_escape::decode_html_entities(dist_info_metadata); @@ -275,10 +272,14 @@ impl SimpleDetailHTML { // Extract the `yanked` field, which should be set on the `data-yanked` // attribute. - let yanked = if let Some(yanked) = link.attributes().get("data-yanked").flatten() { - let yanked = std::str::from_utf8(yanked.as_bytes())?; - let yanked = html_escape::decode_html_entities(yanked); - Some(Box::new(Yanked::Reason(yanked.into()))) + let yanked = if has_attribute(link, "data-yanked") { + if let Some(yanked) = attribute(link, "data-yanked") { + let yanked = std::str::from_utf8(yanked.as_bytes())?; + let yanked = html_escape::decode_html_entities(yanked); + Some(Box::new(Yanked::Reason(yanked.into()))) + } else { + Some(Box::new(Yanked::Bool(true))) + } } else { None }; @@ -286,24 +287,16 @@ impl SimpleDetailHTML { // Extract the `size` field, which should be set on the `data-size` attribute. This isn't // included in PEP 700, which omits the HTML API, but we respect it anyway. Since this // field isn't standardized, we discard errors. - let size = link - .attributes() - .get("data-size") - .flatten() - .and_then(|size| std::str::from_utf8(size.as_bytes()).ok()) - .map(|size| html_escape::decode_html_entities(size)) - .and_then(|size| size.parse().ok()); + let size = attribute(link, "data-size") + .and_then(|size| html_escape::decode_html_entities(&size).parse().ok()); // Extract the `upload-time` field, which should be set on the `data-upload-time` attribute. This isn't // included in PEP 700, which omits the HTML API, but we respect it anyway. Since this // field isn't standardized, we discard errors. - let upload_time = link - .attributes() - .get("data-upload-time") - .flatten() - .and_then(|upload_time| std::str::from_utf8(upload_time.as_bytes()).ok()) - .map(|upload_time| html_escape::decode_html_entities(upload_time)) - .and_then(|upload_time| Timestamp::from_str(&upload_time).ok()); + let upload_time = attribute(link, "data-upload-time").and_then(|upload_time| { + let upload_time = html_escape::decode_html_entities(&upload_time); + Timestamp::from_str(&upload_time).ok() + }); Ok(Some(PypiFile { core_metadata, @@ -336,7 +329,7 @@ impl SimpleIndexHtml { .nodes() .iter() .filter_map(|node| node.as_tag()) - .filter(|link| link.name().as_bytes() == b"a") + .filter(|link| is_tag(link, b"a")) .filter_map(|link| Self::parse_anchor_project_name(link, parser)) .collect::>(); @@ -351,10 +344,7 @@ impl SimpleIndexHtml { /// Returns `None` if the `` doesn't have an `href` attribute or text content. fn parse_anchor_project_name(link: &HTMLTag, parser: &tl::Parser) -> Option { // Extract the href. - link.attributes() - .get("href") - .flatten() - .filter(|bytes| !bytes.as_bytes().is_empty())?; + attribute(link, "href").filter(|href| !href.is_empty())?; // Extract the text content, which should be the project name. let inner_text = link.inner_text(parser); @@ -1657,6 +1647,176 @@ mod tests { "#); } + /// Test that Simple API HTML element names and link `href` are ASCII case-insensitive. + #[test] + fn parse_simple_html_case_insensitively() { + let text = r#" + + + + + + + + + fakeproject-1.2.3.tar.gz + fakeproject-1.2.4.tar.gz + fakeproject-1.2.5.tar.gz + + + "#; + + let result = SimpleDetailHTML::parse( + text, + &DisplaySafeUrl::parse("https://pypi.org/simple/fakeproject/").unwrap(), + ) + .unwrap(); + insta::assert_debug_snapshot!( + ( + &result.project_status, + result.base.as_str(), + &result.files, + ), @r#" + ( + ProjectStatus { + status: Archived, + reason: Some( + "no longer maintained", + ), + }, + "https://index.python.org/", + [ + PypiFile { + core_metadata: Some( + Bool( + true, + ), + ), + filename: "fakeproject-1.2.3.tar.gz", + hashes: Hashes { + md5: None, + sha256: None, + sha384: None, + sha512: None, + blake2b: None, + }, + requires_python: Some( + Ok( + VersionSpecifiers( + [ + VersionSpecifier { + operator: GreaterThanEqual, + version: "3.12", + }, + ], + ), + ), + ), + size: None, + upload_time: None, + url: "/files/fakeproject-1.2.3.tar.gz", + yanked: Some( + Reason( + "broken release", + ), + ), + }, + PypiFile { + core_metadata: Some( + Bool( + false, + ), + ), + filename: "fakeproject-1.2.4.tar.gz", + hashes: Hashes { + md5: None, + sha256: None, + sha384: None, + sha512: None, + blake2b: None, + }, + requires_python: None, + size: None, + upload_time: None, + url: "/files/fakeproject-1.2.4.tar.gz", + yanked: None, + }, + PypiFile { + core_metadata: None, + filename: "fakeproject-1.2.5.tar.gz", + hashes: Hashes { + md5: None, + sha256: None, + sha384: None, + sha512: None, + blake2b: None, + }, + requires_python: None, + size: None, + upload_time: None, + url: "/files/fakeproject-1.2.5.tar.gz", + yanked: Some( + Bool( + true, + ), + ), + }, + ], + ) + "#); + + let text = r#" + + + + fakeproject + + + "#; + + let result = SimpleIndexHtml::parse(text).unwrap(); + insta::assert_debug_snapshot!(result, @r#" + SimpleIndexHtml { + projects: [ + PackageName( + "fakeproject", + ), + ], + } + "#); + } + + /// Test that optional Simple API HTML file attributes are ASCII case-insensitive. + #[test] + fn parse_optional_attributes_case_insensitively() { + let text = r#" + + + + fakeproject-1.2.3.tar.gz + + + "#; + + let result = SimpleDetailHTML::parse( + text, + &DisplaySafeUrl::parse("https://pypi.org/simple/fakeproject/").unwrap(), + ) + .unwrap(); + insta::assert_debug_snapshot!( + (result.files[0].size, result.files[0].upload_time.as_ref()), @r#" + ( + Some( + 15399, + ), + Some( + 2022-11-14T17:14:53.935145Z, + ), + ) + "# + ); + } + // Test parsing project status metadata with emojis in the reason. #[test] fn parse_simple_detail_with_project_status_and_emoji_reason() { diff --git a/crates/uv-client/src/registry_client.rs b/crates/uv-client/src/registry_client.rs index ea47f66f7ed22..b1f40cfee2267 100644 --- a/crates/uv-client/src/registry_client.rs +++ b/crates/uv-client/src/registry_client.rs @@ -24,7 +24,7 @@ use uv_distribution_types::{ BuiltDist, File, IndexCapabilities, IndexFormat, IndexLocations, IndexMetadataRef, IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, IndexUrls, Name, }; -use uv_git::{GitResolver, Reporter}; +use uv_git::{GIT_LFS, GitError, GitHttpSettings, GitResolver, Reporter}; use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream}; use uv_normalize::PackageName; use uv_pep440::Version; @@ -65,7 +65,7 @@ impl<'a> RegistryClientBuilder<'a> { index_strategy: IndexStrategy::default(), torch_backend: None, cache, - base_client_builder, + base_client_builder: base_client_builder.redirect(RedirectPolicy::RetriggerMiddleware), } } @@ -170,8 +170,7 @@ impl<'a> RegistryClientBuilder<'a> { // Build a base client let builder = self .base_client_builder - .indexes(Indexes::from(&self.index_locations)) - .redirect(RedirectPolicy::RetriggerMiddleware); + .indexes(Indexes::from(&self.index_locations)); let client = builder.build()?; @@ -269,9 +268,9 @@ impl RegistryClient { self.client.uncached().for_host(url) } - /// Returns `true` if SSL verification is disabled for the given URL. - pub fn disable_ssl(&self, url: &DisplaySafeUrl) -> bool { - self.client.uncached().disable_ssl(url) + /// Return the [`GitHttpSettings`] for fetching from the given URL. + pub fn git_http_settings(&self, url: &DisplaySafeUrl) -> GitHttpSettings { + self.client.uncached().git_http_settings(url) } /// Return the [`Connectivity`] mode used by this client. @@ -994,14 +993,28 @@ impl RegistryClient { let fetch = git .fetch( &wheel.git, - self.disable_ssl(wheel.git.url()), - self.connectivity() == Connectivity::Offline, + self.git_http_settings(wheel.git.url()), self.cache.bucket(CacheBucket::Git), reporter, ) .await .map_err(ErrorKind::Git)?; + if wheel.git.lfs().enabled() && !fetch.lfs_ready() { + if GIT_LFS.is_err() { + return Err(ErrorKind::MissingWheelGitLfsArtifacts( + wheel.url.to_url(), + GitError::GitLfsNotFound, + ) + .into()); + } + return Err(ErrorKind::MissingWheelGitLfsArtifacts( + wheel.url.to_url(), + GitError::GitLfsNotConfigured, + ) + .into()); + } + // Read the metadata. let file = fs_err::tokio::File::open(fetch.path().join(&wheel.install_path)) .await diff --git a/crates/uv-configuration/Cargo.toml b/crates/uv-configuration/Cargo.toml index 427b505267630..035dae8c4ae6f 100644 --- a/crates/uv-configuration/Cargo.toml +++ b/crates/uv-configuration/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-configuration" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-configuration/README.md b/crates/uv-configuration/README.md index 959323b8b15ca..91d448dca9bdb 100644 --- a/crates/uv-configuration/README.md +++ b/crates/uv-configuration/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-configuration). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-configuration). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-configuration/src/dependency_groups.rs b/crates/uv-configuration/src/dependency_groups.rs index 62f51dd7ff9ad..4196c3a9d4835 100644 --- a/crates/uv-configuration/src/dependency_groups.rs +++ b/crates/uv-configuration/src/dependency_groups.rs @@ -140,6 +140,14 @@ impl DependencyGroups { }) } + /// Helper to make a spec from just --all-groups. + pub fn from_all_groups() -> Self { + Self::from_history(DependencyGroupsHistory { + all_groups: true, + ..Default::default() + }) + } + /// Apply defaults to a base [`DependencyGroups`]. /// /// This is appropriate in projects, where the `dev` group is synced by default. diff --git a/crates/uv-configuration/src/editable.rs b/crates/uv-configuration/src/editable.rs index fa6f7a87e89c2..cba6e368e704f 100644 --- a/crates/uv-configuration/src/editable.rs +++ b/crates/uv-configuration/src/editable.rs @@ -1,8 +1,11 @@ -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +use uv_normalize::PackageName; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub enum EditableMode { #[default] Editable, NonEditable, + NonEditablePackages(Vec), } impl From for EditableMode { @@ -14,3 +17,27 @@ impl From for EditableMode { } } } + +impl EditableMode { + /// Determine the editable installation strategy to use for the given arguments. + pub fn from_args( + editable: Option, + no_editable_package: Vec, + ) -> Option { + match editable { + Some(editable) => Some(Self::from(editable)), + None if no_editable_package.is_empty() => None, + None => Some(Self::NonEditablePackages(no_editable_package)), + } + } + + /// Return the editable override for a specific package, if any. + pub fn for_package(&self, package_name: &PackageName) -> Option { + match self { + Self::Editable => Some(true), + Self::NonEditable => Some(false), + Self::NonEditablePackages(packages) if packages.contains(package_name) => Some(false), + Self::NonEditablePackages(_) => None, + } + } +} diff --git a/crates/uv-console/Cargo.toml b/crates/uv-console/Cargo.toml index 34538d4e06caa..5ccfd438bbe5d 100644 --- a/crates/uv-console/Cargo.toml +++ b/crates/uv-console/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-console" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-console/README.md b/crates/uv-console/README.md index 42db73424175d..2920dc880b1e0 100644 --- a/crates/uv-console/README.md +++ b/crates/uv-console/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-console). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-console). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-dev/Cargo.toml b/crates/uv-dev/Cargo.toml index d8dfd39aa3680..01197a53f05b2 100644 --- a/crates/uv-dev/Cargo.toml +++ b/crates/uv-dev/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-dev" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" publish = false @@ -21,6 +21,7 @@ uv-client = { workspace = true } uv-configuration = { workspace = true } uv-distribution-filename = { workspace = true } uv-distribution-types = { workspace = true } +uv-errors = { workspace = true } uv-extract = { workspace = true } uv-git = { workspace = true } uv-installer = { workspace = true } @@ -43,7 +44,6 @@ fs-err = { workspace = true, features = ["tokio"] } futures = { workspace = true } itertools = { workspace = true } markdown = { version = "1.0.0" } -owo-colors = { workspace = true } poloto = { version = "19.1.2", optional = true } pretty_assertions = { version = "1.4.1" } reqwest = { workspace = true, features = ["stream"] } @@ -63,6 +63,9 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } uv-performance-memory-allocator = { path = "../uv-performance-memory-allocator", optional = true } walkdir = { workspace = true } +[dev-dependencies] +insta = { workspace = true } + [lib] name = "uv_dev" doctest = false diff --git a/crates/uv-dev/README.md b/crates/uv-dev/README.md index 770acc88e220c..5a709855cc884 100644 --- a/crates/uv-dev/README.md +++ b/crates/uv-dev/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-dev). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-dev). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-dev/src/generate_cli_reference.rs b/crates/uv-dev/src/generate_cli_reference.rs index 1ef499d816c34..e4898ca52d055 100644 --- a/crates/uv-dev/src/generate_cli_reference.rs +++ b/crates/uv-dev/src/generate_cli_reference.rs @@ -204,7 +204,7 @@ fn generate_command<'a>(output: &mut String, command: &'a Command, parents: &mut let id = format!("{name_key}--{}", arg.get_id()); output.push_str(&format!("
")); output.push_str(&format!( - "{}", + "{}", arg.get_id().to_string().to_uppercase(), )); output.push_str("
"); @@ -342,3 +342,26 @@ fn emit_possible_options(opt: &clap::Arg, output: &mut String) { output.push_str(&markdown::to_html(&value)); } } + +#[cfg(test)] +mod tests { + use clap::{Arg, Command}; + use insta::assert_snapshot; + + use super::generate_command; + + #[test] + fn generates_linked_positional_argument() { + let mut command = Command::new("sample").arg(Arg::new("path").index(1)); + command.build(); + + let mut output = String::new(); + generate_command(&mut output, &command, &mut Vec::new()); + let argument = output + .lines() + .find(|line| line.starts_with("
PATH
"##); + } +} diff --git a/crates/uv-dev/src/main.rs b/crates/uv-dev/src/main.rs index c515f84c056bf..43f9fc04c62e4 100644 --- a/crates/uv-dev/src/main.rs +++ b/crates/uv-dev/src/main.rs @@ -4,8 +4,6 @@ use std::process::ExitCode; use std::str::FromStr; use std::time::Instant; -use anstream::eprintln; -use owo_colors::OwoColorize; use tracing::{debug, trace}; use tracing_durations_export::DurationsLayerBuilder; use tracing_durations_export::plot::PlotConfig; @@ -69,10 +67,8 @@ async fn main() -> ExitCode { debug!("Took {}ms", start.elapsed().as_millis()); if let Err(err) = result { trace!("Error trace: {err:?}"); - eprintln!("{}", "uv-dev failed".red().bold()); - for err in err.chain() { - eprintln!(" {}: {}", "Caused by".red().bold(), err.to_string().trim()); - } + let err = err.context("uv-dev failed"); + uv_errors::write_error_chain(err.as_ref()).expect("writing to stderr should not fail"); ExitCode::FAILURE } else { ExitCode::SUCCESS diff --git a/crates/uv-dirs/Cargo.toml b/crates/uv-dirs/Cargo.toml index 1a64c48d64d2b..f2c957ebf870a 100644 --- a/crates/uv-dirs/Cargo.toml +++ b/crates/uv-dirs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-dirs" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-dirs/README.md b/crates/uv-dirs/README.md index e760c2925366e..190890fd50560 100644 --- a/crates/uv-dirs/README.md +++ b/crates/uv-dirs/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-dirs). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-dirs). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-dispatch/Cargo.toml b/crates/uv-dispatch/Cargo.toml index 06b128e1876f0..5c076afb13f9f 100644 --- a/crates/uv-dispatch/Cargo.toml +++ b/crates/uv-dispatch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-dispatch" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-dispatch/README.md b/crates/uv-dispatch/README.md index 41e3b4228e4d0..ccfdd358657ae 100644 --- a/crates/uv-dispatch/README.md +++ b/crates/uv-dispatch/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-dispatch). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-dispatch). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-distribution-filename/Cargo.toml b/crates/uv-distribution-filename/Cargo.toml index 0148d53e3faf3..37bb26becefaf 100644 --- a/crates/uv-distribution-filename/Cargo.toml +++ b/crates/uv-distribution-filename/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-distribution-filename" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-distribution-filename/README.md b/crates/uv-distribution-filename/README.md index 4b0db5ac38248..037568e6938fa 100644 --- a/crates/uv-distribution-filename/README.md +++ b/crates/uv-distribution-filename/README.md @@ -5,9 +5,9 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The source can be found -[here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-distribution-filename). +[here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-distribution-filename). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-distribution-types/Cargo.toml b/crates/uv-distribution-types/Cargo.toml index bca2357cc4f53..c2dc98bcddbd4 100644 --- a/crates/uv-distribution-types/Cargo.toml +++ b/crates/uv-distribution-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-distribution-types" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } @@ -56,4 +56,11 @@ version-ranges = { workspace = true } toml = { workspace = true } [features] -schemars = ["dep:schemars", "uv-auth/schemars", "uv-redacted/schemars"] +schemars = [ + "dep:schemars", + "uv-auth/schemars", + "uv-normalize/schemars", + "uv-pep508/schemars", + "uv-redacted/schemars", + "uv-small-str/schemars", +] diff --git a/crates/uv-distribution-types/README.md b/crates/uv-distribution-types/README.md index 11d1758b9ed04..f9dc2db4aaff6 100644 --- a/crates/uv-distribution-types/README.md +++ b/crates/uv-distribution-types/README.md @@ -5,9 +5,9 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The source can be found -[here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-distribution-types). +[here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-distribution-types). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-distribution-types/src/installed_modules.rs b/crates/uv-distribution-types/src/installed_modules.rs new file mode 100644 index 0000000000000..d53dd5cd44989 --- /dev/null +++ b/crates/uv-distribution-types/src/installed_modules.rs @@ -0,0 +1,276 @@ +//! Discovers importable modules provided by an installed wheel. +//! +//! Installed wheels record installed paths in `-.dist-info/RECORD`. Python source +//! files, legacy sourceless bytecode, and recognized native extension modules located under the +//! import root contribute a [`ModuleName`] and its parent package prefixes. +//! +//! This is intentionally file-based: it does not infer modules exposed through `.pth` files, +//! legacy namespace declarations in `__init__.py`, or `.pyi`-only stub distributions. + +use std::collections::BTreeSet; +use std::path::{Component, Path}; + +use fs_err::File; +use uv_fs::normalize_path; +use uv_install_wheel::read_record; +use uv_pypi_types::ModuleName; + +use crate::installed::{InstalledDist, InstalledDistError}; + +impl InstalledDist { + /// Read the modules provided by this installed distribution. + pub fn read_modules( + &self, + extension_suffixes: &[Box], + ) -> Result, InstalledDistError> { + let dist_info = self.install_path(); + if !has_extension(dist_info, "dist-info") { + return Ok(BTreeSet::new()); + } + + let record_path = dist_info.join("RECORD"); + let record = read_record(File::open(&record_path)?)?; + + let mut modules = BTreeSet::new(); + for entry in record { + add_record_module(&entry.path, extension_suffixes, &mut modules); + } + + Ok(modules) + } +} + +fn add_record_module( + path: &str, + extension_suffixes: &[Box], + modules: &mut BTreeSet, +) { + let Some(components) = record_path_components(path) else { + return; + }; + let Some((file_name, parents)) = components.split_last() else { + return; + }; + let file_name = file_name.as_ref(); + + // Metadata and other entries under `.dist-info` directories are not modules. + if components + .iter() + .any(|component| has_extension(component.as_ref(), "dist-info")) + { + return; + } + // Files in a `.data` directory that were not relocated into the import root are not modules. + // Relocated files are recorded at their installed paths instead. + if components + .first() + .is_some_and(|component| has_extension(component.as_ref(), "data")) + { + return; + } + + let mut module_components = parents + .iter() + .map(std::convert::AsRef::as_ref) + .collect::>(); + // We intentionally skip `.pyi` files here because we're looking for runtime module ownership. + // Type stubs will require separate ownership modeling. + if file_name == "__init__.py" { + // The parent path is the package. + } else if let Some(stem) = file_name.strip_suffix(".py") { + module_components.push(stem); + } else if let Some(stem) = bytecode_module_stem(file_name, parents) { + if stem != "__init__" { + module_components.push(stem); + } + } else if let Some(stem) = { + // Python reports the recognized suffixes in import lookup order through + // `importlib.machinery.EXTENSION_SUFFIXES`; preserve that order so a generic suffix such + // as `.so` does not consume a more-specific suffix such as `.abi3.so`. + extension_suffixes.iter().find_map(|suffix| { + let stem = file_name.strip_suffix(suffix.as_ref())?; + (!stem.is_empty()).then_some(stem) + }) + } { + if stem != "__init__" { + module_components.push(stem); + } + } else { + return; + } + + add_module_components(&module_components, modules); +} + +fn record_path_components(path: &str) -> Option>> { + let normalized = normalize_path(Path::new(path)); + let path = normalized.as_ref(); + + // `RECORD` can include absolute paths and relative paths that leave the directory containing + // `.dist-info`, for example installed scripts. Those entries cannot describe modules here. + if path.is_absolute() { + return None; + } + + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Normal(component) => { + components.push(Box::from(component.to_str()?)); + } + Component::CurDir => {} + Component::ParentDir | Component::Prefix(_) | Component::RootDir => return None, + } + } + + Some(components) +} + +/// Return the module stem for importable sourceless bytecode in a `RECORD` path. +/// +/// CPython can import `package/module.pyc` directly when only bytecode is installed. In +/// contrast, `package/__pycache__/module.cpython-312.pyc` is not an import source without +/// `package/module.py`. +fn bytecode_module_stem<'a>(file_name: &'a str, parents: &[Box]) -> Option<&'a str> { + let stem = file_name.strip_suffix(".pyc")?; + if parents + .last() + .is_some_and(|parent| parent.as_ref() == "__pycache__") + { + // A `.pyc` file in `__pycache__` does not make the module importable + // without the corresponding source file. Sourceless imports use the + // legacy `module.pyc` location instead. + return None; + } + + Some(stem) +} + +fn has_extension(path: impl AsRef, extension: &str) -> bool { + path.as_ref() + .extension() + .is_some_and(|candidate| candidate == extension) +} + +fn add_module_components(components: &[&str], modules: &mut BTreeSet) { + let Ok(module) = ModuleName::from_components(components.iter().copied()) else { + return; + }; + + modules.extend(module.prefixes()); +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use uv_pypi_types::ModuleName; + + use super::add_record_module; + + fn extension_suffixes() -> Vec> { + [ + ".cpython-312-darwin.so", + ".cpython-314td-darwin.so", + ".abi3.so", + ".cp312-win_amd64.pyd", + ".so", + ] + .into_iter() + .map(Box::from) + .collect() + } + + fn module_names(modules: BTreeSet) -> String { + modules + .into_iter() + .map(|module| module.to_string()) + .collect::>() + .join("\n") + } + + #[test] + fn record_module_normalizes_record_paths() { + let mut modules = BTreeSet::new(); + add_record_module("./package/../café.py", &[], &mut modules); + + assert_eq!(module_names(modules), "café"); + } + + #[test] + fn record_module_from_legacy_bytecode() { + let mut modules = BTreeSet::new(); + add_record_module("package/module.pyc", &[], &mut modules); + add_record_module("legacy.pyc", &[], &mut modules); + + assert_eq!(module_names(modules), "legacy\npackage\npackage.module"); + } + + #[test] + fn record_module_ignores_pycache_bytecode() { + let mut modules = BTreeSet::new(); + add_record_module( + "package/__pycache__/module.cpython-312.opt-1.pyc", + &[], + &mut modules, + ); + add_record_module( + "package/__pycache__/__init__.cpython-312.pyc", + &[], + &mut modules, + ); + + assert_eq!(module_names(modules), ""); + } + + #[test] + fn record_module_from_extension_module() { + let extension_suffixes = extension_suffixes(); + let mut modules = BTreeSet::new(); + add_record_module( + "package/extension.cpython-312-darwin.so", + &extension_suffixes, + &mut modules, + ); + add_record_module( + "package/free_threaded.cpython-314td-darwin.so", + &extension_suffixes, + &mut modules, + ); + add_record_module("package/limited.abi3.so", &extension_suffixes, &mut modules); + add_record_module( + "package/windows.cp312-win_amd64.pyd", + &extension_suffixes, + &mut modules, + ); + add_record_module( + "package/__init__.cpython-312-darwin.so", + &extension_suffixes, + &mut modules, + ); + add_record_module("plain.so", &extension_suffixes, &mut modules); + + assert_eq!( + module_names(modules), + "package\npackage.extension\npackage.free_threaded\npackage.limited\npackage.windows\nplain" + ); + } + + #[test] + fn record_module_ignores_unrecognized_extension_suffixes() { + let extension_suffixes = extension_suffixes(); + let mut modules = BTreeSet::new(); + add_record_module( + "package/extension.not-an-extension-tag.so", + &extension_suffixes, + &mut modules, + ); + add_record_module( + "package/bogus.pypynonsense.so", + &extension_suffixes, + &mut modules, + ); + + assert_eq!(module_names(modules), ""); + } +} diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index d37d0be547271..89270c4ef53ca 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -106,6 +106,7 @@ mod index; mod index_name; mod index_url; mod installed; +mod installed_modules; mod known_platform; mod origin; mod pip_index; diff --git a/crates/uv-distribution-types/src/requirement.rs b/crates/uv-distribution-types/src/requirement.rs index c2d0b1c27f113..73917e9f5b811 100644 --- a/crates/uv-distribution-types/src/requirement.rs +++ b/crates/uv-distribution-types/src/requirement.rs @@ -382,10 +382,11 @@ impl Display for Requirement { if let Some(reference) = git.reference().as_url_rev() { write!(f, "@{reference}")?; } - writeln!(f, "#path={}", install_path.display())?; + write!(f, "#path={}", install_path.display())?; if git.lfs().enabled() { - writeln!(f, "&lfs=true")?; + write!(f, "&lfs=true")?; } + writeln!(f)?; } RequirementSource::Path { url, .. } => { write!(f, " @ {url}")?; @@ -882,7 +883,11 @@ impl Display for RequirementSource { if let Some(reference) = git.reference().as_url_rev() { write!(f, "@{reference}")?; } - writeln!(f, "#path={}", install_path.display())?; + write!(f, "#path={}", install_path.display())?; + if git.lfs().enabled() { + write!(f, "&lfs=true")?; + } + writeln!(f)?; } Self::Path { url, .. } => { write!(f, "{url}")?; @@ -1288,4 +1293,30 @@ mod tests { let deserialized: Requirement = toml::from_str(&raw).unwrap(); assert_eq!(requirement, deserialized); } + + #[test] + fn display_git_path_lfs() { + let source: RequirementSource = toml::from_str( + r#"git = "https://github.com/astral-sh/archive-in-git-test?lfs=true&path=archives%2Finiconfig-2.0.0-py3-none-any.whl""#, + ) + .unwrap(); + + assert_eq!( + source.to_string(), + " git+https://github.com/astral-sh/archive-in-git-test#path=archives/iniconfig-2.0.0-py3-none-any.whl&lfs=true\n" + ); + + let requirement = Requirement { + name: "iniconfig".parse().unwrap(), + extras: Box::new([]), + groups: Box::new([]), + marker: MarkerTree::TRUE, + source, + origin: None, + }; + assert_eq!( + requirement.to_string(), + "iniconfig @ git+https://github.com/astral-sh/archive-in-git-test#path=archives/iniconfig-2.0.0-py3-none-any.whl&lfs=true\n" + ); + } } diff --git a/crates/uv-distribution/Cargo.toml b/crates/uv-distribution/Cargo.toml index 1c89269135f43..48795e4e471df 100644 --- a/crates/uv-distribution/Cargo.toml +++ b/crates/uv-distribution/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-distribution" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-distribution/README.md b/crates/uv-distribution/README.md index b6f118383eaa5..0742337f2d9a4 100644 --- a/crates/uv-distribution/README.md +++ b/crates/uv-distribution/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-distribution). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-distribution). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-distribution/src/distribution_database.rs b/crates/uv-distribution/src/distribution_database.rs index b4ec52e861ba2..ace2582aba67e 100644 --- a/crates/uv-distribution/src/distribution_database.rs +++ b/crates/uv-distribution/src/distribution_database.rs @@ -24,6 +24,7 @@ use uv_distribution_types::{ }; use uv_extract::hash::Hasher; use uv_fs::write_atomic; +use uv_git::{GIT_LFS, GitError}; use uv_install_wheel::validate_and_heal_record; use uv_platform_tags::Tags; use uv_pypi_types::{HashDigest, HashDigests, PyProjectToml}; @@ -366,13 +367,25 @@ impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> { .git() .fetch( &wheel.git, - self.client.unmanaged.disable_ssl(wheel.git.url()), - self.client.unmanaged.connectivity() == Connectivity::Offline, + self.client.unmanaged.git_http_settings(wheel.git.url()), self.build_context.cache().bucket(CacheBucket::Git), self.reporter.clone().map(::into_git_reporter), ) .await?; + if wheel.git.lfs().enabled() && !fetch.lfs_ready() { + if GIT_LFS.is_err() { + return Err(Error::MissingWheelGitLfsArtifacts( + wheel.url.to_url(), + GitError::GitLfsNotFound, + )); + } + return Err(Error::MissingWheelGitLfsArtifacts( + wheel.url.to_url(), + GitError::GitLfsNotConfigured, + )); + } + let git_sha = fetch.git().precise().expect("Exact commit after checkout"); let cache_entry = self.build_context.cache().entry( CacheBucket::Wheels, diff --git a/crates/uv-distribution/src/error.rs b/crates/uv-distribution/src/error.rs index cbe83f71f9aed..43b55d79af75e 100644 --- a/crates/uv-distribution/src/error.rs +++ b/crates/uv-distribution/src/error.rs @@ -50,6 +50,8 @@ pub enum Error { Reqwest(#[from] WrappedReqwestError), #[error(transparent)] Client(#[from] uv_client::Error), + #[error(transparent)] + ClientBuild(#[from] uv_client::ClientBuildError), // Cache writing error #[error("Failed to read from the distribution cache")] @@ -131,7 +133,9 @@ pub enum Error { #[error("The source distribution `{}` has no subdirectory `{}`", _0, _1.display())] MissingSubdirectory(DisplaySafeUrl, PathBuf), #[error("The source distribution `{0}` is missing Git LFS artifacts.")] - MissingGitLfsArtifacts(DisplaySafeUrl, #[source] GitError), + MissingSourceDistGitLfsArtifacts(DisplaySafeUrl, #[source] GitError), + #[error("The wheel `{0}` is missing Git LFS artifacts.")] + MissingWheelGitLfsArtifacts(DisplaySafeUrl, #[source] GitError), #[error("Failed to extract static metadata from `PKG-INFO`")] PkgInfo(#[source] uv_pypi_types::MetadataError), #[error("The source distribution is missing a `pyproject.toml` file")] diff --git a/crates/uv-distribution/src/lib.rs b/crates/uv-distribution/src/lib.rs index a938a450855f1..f5c83eeb5f4e7 100644 --- a/crates/uv-distribution/src/lib.rs +++ b/crates/uv-distribution/src/lib.rs @@ -8,7 +8,7 @@ pub use metadata::{ SourcedDependencyGroups, }; pub use reporter::Reporter; -pub use source::prune; +pub use source::{StaticMetadataDatabase, prune}; mod archive; mod distribution_database; diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index c33ab7f6eac74..08e736da732b6 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -533,11 +533,13 @@ impl LoweredRequirement { return Ok(Self(Requirement::from(requirement))); }; - let ParsedUrl::Directory(directory) = &url.parsed_url else { - return Ok(Self(Requirement::from(requirement))); + let (install_path, is_archive) = match &url.parsed_url { + ParsedUrl::Directory(directory) => (directory.install_path.as_ref(), false), + ParsedUrl::Path(path) => (path.install_path.as_ref(), true), + _ => return Ok(Self(Requirement::from(requirement))), }; - let install_path = git_path(&directory.install_path)?; + let install_path = git_path(install_path)?; let fetch_root = git_path(git_member.fetch_root)?; if !install_path.starts_with(&fetch_root) { return Ok(Self(Requirement::from(requirement))); @@ -548,7 +550,11 @@ impl LoweredRequirement { groups: Box::new([]), extras: requirement.extras, marker: requirement.marker, - source: git_source_from_path(&install_path, git_member)?, + source: if is_archive { + git_archive_source_from_path(&install_path, git_member)? + } else { + git_directory_source_from_path(&install_path, git_member)? + }, origin: requirement.origin, })) } @@ -834,7 +840,7 @@ fn path_source( }; if is_dir { if let Some(git_member) = git_member { - return git_source_from_path(install_path, git_member); + return git_directory_source_from_path(install_path, git_member); } if editable == Some(true) { @@ -870,7 +876,7 @@ fn path_source( } } else { if let Some(git_member) = git_member { - return git_path_source_from_path(install_path, git_member); + return git_archive_source_from_path(install_path, git_member); } if editable == Some(true) { return Err(LoweringError::EditableFile(url.to_string())); @@ -887,7 +893,7 @@ fn path_source( } } -fn git_source_from_path( +fn git_directory_source_from_path( install_path: impl AsRef, git_member: &GitWorkspaceMember, ) -> Result { @@ -913,7 +919,7 @@ fn git_source_from_path( }) } -fn git_path_source_from_path( +fn git_archive_source_from_path( install_path: impl AsRef, git_member: &GitWorkspaceMember, ) -> Result { diff --git a/crates/uv-distribution/src/source/mod.rs b/crates/uv-distribution/src/source/mod.rs index a2a8ddf0e379b..316d2dd5537d6 100644 --- a/crates/uv-distribution/src/source/mod.rs +++ b/crates/uv-distribution/src/source/mod.rs @@ -25,19 +25,20 @@ use uv_auth::CredentialsCache; use uv_cache::{Cache, CacheBucket, CacheEntry, CacheShard, Removal, WheelCache}; use uv_cache_info::CacheInfo; use uv_client::{ - CacheControl, CachedClientError, Connectivity, DataWithCachePolicy, RegistryClient, + BaseClientBuilder, CacheControl, CachedClientError, Connectivity, DataWithCachePolicy, + RegistryClient, }; use uv_configuration::{BuildKind, BuildOutput, NoSources}; use uv_distribution_filename::{SourceDistExtension, WheelFilename}; use uv_distribution_types::{ BuildInfo, BuildVariables, BuildableSource, ConfigSettings, DirectorySourceUrl, ExtraBuildRequirement, GitDirectorySourceUrl, GitPathSourceUrl, HashPolicy, Hashed, IndexUrl, - PathSourceUrl, SourceDist, SourceUrl, + PathSourceUrl, RequirementSource, RequiresPython, SourceDist, SourceUrl, }; use uv_extract::hash::Hasher; use uv_fs::{Simplified, rename_with_retry, write_atomic}; -use uv_git::{Fetch, GIT_LFS, GitError}; -use uv_git_types::{GitHubRepository, GitOid}; +use uv_git::{Fetch, GIT_LFS, GitError, GitHttpSettings, GitResolver}; +use uv_git_types::{GitHubRepository, GitOid, GitUrl}; use uv_metadata::read_archive_metadata; use uv_normalize::PackageName; use uv_pep440::{Version, release_specifiers_to_ranges}; @@ -57,6 +58,155 @@ use crate::{Reporter, RequiresDist}; mod built_wheel_metadata; mod revision; +/// Access distribution metadata without requiring a build interpreter. +/// +/// This is intended for metadata operations that occur before selecting an interpreter, such as +/// choosing a tool Python from `requires-python`. +pub struct StaticMetadataDatabase<'a, 'client> { + client_builder: &'a BaseClientBuilder<'client>, + git: &'a GitResolver, + cache: &'a Cache, +} + +/// A direct source tree materialized on disk for static metadata inspection. +#[derive(Debug)] +struct MaterializedSourceTree(Box); + +impl MaterializedSourceTree { + /// Return the on-disk path for this source tree. + fn path(&self) -> &Path { + &self.0 + } +} + +impl<'a, 'client> StaticMetadataDatabase<'a, 'client> { + /// Create a [`StaticMetadataDatabase`] for an invocation. + pub fn new( + client_builder: &'a BaseClientBuilder<'client>, + git: &'a GitResolver, + cache: &'a Cache, + ) -> Self { + Self { + client_builder, + git, + cache, + } + } + + /// Materialize a direct source tree, if the requirement identifies one. + /// + /// Directory requirements are already materialized. Git source trees are fetched into the + /// Git cache and returned at the requested subdirectory. + async fn materialize_source_tree( + &self, + source: &RequirementSource, + ) -> Result, Error> { + match source { + RequirementSource::Directory { install_path, .. } => Ok(Some(MaterializedSourceTree( + install_path.to_path_buf().into_boxed_path(), + ))), + RequirementSource::GitDirectory { + git, + subdirectory, + url, + } => { + let client = self.client_builder.build()?; + let fetch = fetch_git_source_tree( + self.git, + git, + url.to_url(), + subdirectory.as_deref(), + client.git_http_settings(git.url()), + self.cache, + None, + ) + .await?; + + if let Some(subdirectory) = subdirectory { + let source_tree = fetch.path().join(subdirectory); + Ok(Some(MaterializedSourceTree(source_tree.into_boxed_path()))) + } else { + Ok(Some(MaterializedSourceTree( + fetch.path().to_path_buf().into_boxed_path(), + ))) + } + } + _ => Ok(None), + } + } + + /// Read static [`RequiresPython`] from an already materialized source tree. + async fn source_tree_requires_python( + &self, + source_tree: &MaterializedSourceTree, + ) -> Result, Error> { + let pyproject_toml = match read_pyproject_toml(source_tree.path(), None).await { + Ok(pyproject_toml) => pyproject_toml, + Err(Error::MissingPyprojectToml) => return Ok(None), + Err(err) => return Err(err), + }; + + match pyproject_toml.requires_python() { + Ok(Some(requires_python)) => { + Ok(Some(RequiresPython::from_specifiers(&requires_python))) + } + Ok(None) | Err(uv_pypi_types::MetadataError::FieldNotFound("project")) => Ok(None), + Err(uv_pypi_types::MetadataError::DynamicField("requires-python")) => { + debug!("Ignoring dynamic `requires-python` in source tree"); + Ok(None) + } + Err(err) => Err(Error::PyprojectToml(err)), + } + } + + /// Read static [`RequiresPython`] from a direct source-tree requirement. + pub async fn requires_python( + &self, + source: &RequirementSource, + ) -> Result, Error> { + let Some(source_tree) = self.materialize_source_tree(source).await? else { + return Ok(None); + }; + self.source_tree_requires_python(&source_tree).await + } +} + +/// Fetch and validate a Git source tree. +async fn fetch_git_source_tree( + git_resolver: &GitResolver, + git: &GitUrl, + url: DisplaySafeUrl, + subdirectory: Option<&Path>, + http_settings: GitHttpSettings, + cache: &Cache, + reporter: Option>, +) -> Result { + let fetch = git_resolver + .fetch(git, http_settings, cache.bucket(CacheBucket::Git), reporter) + .await?; + + if let Some(subdirectory) = subdirectory + && !fetch.path().join(subdirectory).is_dir() + { + return Err(Error::MissingSubdirectory(url, subdirectory.to_path_buf())); + } + + if git.lfs().enabled() && !fetch.lfs_ready() { + if GIT_LFS.is_err() { + return Err(Error::MissingSourceDistGitLfsArtifacts( + url, + GitError::GitLfsNotFound, + )); + } + return Err(Error::MissingSourceDistGitLfsArtifacts( + url, + GitError::GitLfsNotConfigured, + )); + } + + Ok(fetch) +} + /// Fetch and build a source distribution from a remote source, or from a local cache. pub(crate) struct SourceDistributionBuilder<'a, T: BuildContext> { build_context: &'a T, @@ -1607,6 +1757,20 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { cache_shard: &CacheShard, hashes: HashPolicy<'_>, ) -> Result { + // Validate that LFS artifacts were fully initialized. + if resource.git.lfs().enabled() && !fetch.lfs_ready() { + if GIT_LFS.is_err() { + return Err(Error::MissingSourceDistGitLfsArtifacts( + resource.url.to_url(), + GitError::GitLfsNotFound, + )); + } + return Err(Error::MissingSourceDistGitLfsArtifacts( + resource.url.to_url(), + GitError::GitLfsNotConfigured, + )); + } + // Verify that the archive exists. let install_path = fetch.path().join(&resource.path); if !install_path.is_file() { @@ -1654,8 +1818,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .git() .fetch( resource.git, - client.unmanaged.disable_ssl(resource.git.url()), - client.unmanaged.connectivity() == Connectivity::Offline, + client.unmanaged.git_http_settings(resource.git.url()), self.build_context.cache().bucket(CacheBucket::Git), self.reporter .clone() @@ -1762,8 +1925,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .git() .fetch( resource.git, - client.unmanaged.disable_ssl(resource.git.url()), - client.unmanaged.connectivity() == Connectivity::Offline, + client.unmanaged.git_http_settings(resource.git.url()), self.build_context.cache().bucket(CacheBucket::Git), self.reporter .clone() @@ -1922,44 +2084,18 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { return Err(Error::HashesNotSupportedGit(source.to_string())); } - // Fetch the Git repository. - let fetch = self - .build_context - .git() - .fetch( - resource.git, - client.unmanaged.disable_ssl(resource.git.url()), - client.unmanaged.connectivity() == Connectivity::Offline, - self.build_context.cache().bucket(CacheBucket::Git), - self.reporter - .clone() - .map(|reporter| reporter.into_git_reporter()), - ) - .await?; - - // Validate that the subdirectory exists. - if let Some(subdirectory) = resource.subdirectory { - if !fetch.path().join(subdirectory).is_dir() { - return Err(Error::MissingSubdirectory( - resource.url.to_url(), - subdirectory.to_path_buf(), - )); - } - } - - // Validate that LFS artifacts were fully initialized - if resource.git.lfs().enabled() && !fetch.lfs_ready() { - if GIT_LFS.is_err() { - return Err(Error::MissingGitLfsArtifacts( - resource.url.to_url(), - GitError::GitLfsNotFound, - )); - } - return Err(Error::MissingGitLfsArtifacts( - resource.url.to_url(), - GitError::GitLfsNotConfigured, - )); - } + let fetch = fetch_git_source_tree( + self.build_context.git(), + resource.git, + resource.url.to_url(), + resource.subdirectory, + client.unmanaged.git_http_settings(resource.git.url()), + self.build_context.cache(), + self.reporter + .clone() + .map(|reporter| reporter.into_git_reporter()), + ) + .await?; let git_sha = fetch.git().precise().expect("Exact commit after checkout"); let cache_shard = self.build_context.cache().shard( @@ -2137,44 +2273,18 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { } } - // Fetch the Git repository. - let fetch = self - .build_context - .git() - .fetch( - resource.git, - client.unmanaged.disable_ssl(resource.git.url()), - client.unmanaged.connectivity() == Connectivity::Offline, - self.build_context.cache().bucket(CacheBucket::Git), - self.reporter - .clone() - .map(|reporter| reporter.into_git_reporter()), - ) - .await?; - - // Validate that the subdirectory exists. - if let Some(subdirectory) = resource.subdirectory { - if !fetch.path().join(subdirectory).is_dir() { - return Err(Error::MissingSubdirectory( - resource.url.to_url(), - subdirectory.to_path_buf(), - )); - } - } - - // Validate that LFS artifacts were fully initialized - if resource.git.lfs().enabled() && !fetch.lfs_ready() { - if GIT_LFS.is_err() { - return Err(Error::MissingGitLfsArtifacts( - resource.url.to_url(), - GitError::GitLfsNotFound, - )); - } - return Err(Error::MissingGitLfsArtifacts( - resource.url.to_url(), - GitError::GitLfsNotConfigured, - )); - } + let fetch = fetch_git_source_tree( + self.build_context.git(), + resource.git, + resource.url.to_url(), + resource.subdirectory, + client.unmanaged.git_http_settings(resource.git.url()), + self.build_context.cache(), + self.reporter + .clone() + .map(|reporter| reporter.into_git_reporter()), + ) + .await?; let git_sha = fetch.git().precise().expect("Exact commit after checkout"); let cache_shard = self.build_context.cache().shard( @@ -2418,8 +2528,7 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> { .git() .fetch( git, - client.unmanaged.disable_ssl(git.url()), - client.unmanaged.connectivity() == Connectivity::Offline, + client.unmanaged.git_http_settings(git.url()), self.build_context.cache().bucket(CacheBucket::Git), self.reporter .clone() diff --git a/crates/uv-errors/Cargo.toml b/crates/uv-errors/Cargo.toml index 42a2a65a83841..39593c4b54ae5 100644 --- a/crates/uv-errors/Cargo.toml +++ b/crates/uv-errors/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-errors" -version = "0.0.49" +version = "0.0.50" description = "Common error traits for uv." edition = { workspace = true } rust-version = { workspace = true } @@ -10,7 +10,17 @@ authors = { workspace = true } license = { workspace = true } [dependencies] +anstream = { workspace = true } owo-colors = { workspace = true } +terminal_size = { workspace = true } +textwrap = { workspace = true } +uv-static = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +indoc = { workspace = true } +insta = { workspace = true } +thiserror = { workspace = true } [lints] workspace = true diff --git a/crates/uv-errors/README.md b/crates/uv-errors/README.md index 46ec3dcb1855d..6b831a5d8ffda 100644 --- a/crates/uv-errors/README.md +++ b/crates/uv-errors/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-errors). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-errors). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-errors/src/lib.rs b/crates/uv-errors/src/lib.rs index a6229f662f8f0..f2c63574dee3c 100644 --- a/crates/uv-errors/src/lib.rs +++ b/crates/uv-errors/src/lib.rs @@ -1,7 +1,13 @@ +mod line_wrap; + use std::borrow::Cow; +use std::error::Error; use std::fmt; +use std::iter; + +use owo_colors::{AnsiColors, DynColor, OwoColorize}; -use owo_colors::OwoColorize; +use line_wrap::{get_wrap_width, wrap_text}; /// An error that may carry user-facing hints. /// @@ -129,9 +135,157 @@ impl fmt::Display for HintPrefix { } } +/// Options for formatting an error chain. +#[must_use] +pub struct ErrorOptions<'a, C = AnsiColors, W = Stderr> { + level: Cow<'a, str>, + color: C, + hints: Hints<'a>, + width_override: Option, + stream: W, +} + +/// A standard-error writer for formatted error chains. +#[derive(Debug, Clone, Copy, Default)] +pub struct Stderr; + +impl fmt::Write for Stderr { + fn write_str(&mut self, output: &str) -> fmt::Result { + anstream::eprint!("{output}"); + Ok(()) + } +} + +impl Default for ErrorOptions<'_, AnsiColors, Stderr> { + fn default() -> Self { + Self { + level: Cow::Borrowed("error"), + color: AnsiColors::Red, + hints: Hints::none(), + width_override: None, + stream: Stderr, + } + } +} + +impl<'a, C, W> ErrorOptions<'a, C, W> { + /// Use a custom level prefix, such as `warning`. + pub fn with_level(mut self, level: impl Into>) -> Self { + self.level = level.into(); + self + } + + /// Use a custom color for the level and cause prefixes. + pub fn with_color(self, color: D) -> ErrorOptions<'a, D, W> { + ErrorOptions { + level: self.level, + color, + hints: self.hints, + width_override: self.width_override, + stream: self.stream, + } + } + + /// Render additional user-facing hints after the error chain. + pub fn with_hints(mut self, hints: Hints<'a>) -> Self { + self.hints = hints; + self + } + + /// Override the terminal width used for wrapping. + /// + /// This is primarily useful for testing. + pub fn with_width_override(mut self, width_override: usize) -> Self { + self.width_override = Some(width_override); + self + } + + /// Write the rendered error chain to a custom stream. + pub fn with_stream(self, stream: D) -> ErrorOptions<'a, C, D> { + ErrorOptions { + level: self.level, + color: self.color, + hints: self.hints, + width_override: self.width_override, + stream, + } + } +} + +/// Format an error chain to standard error using the default level and color. +pub fn write_error_chain(err: &dyn Error) -> fmt::Result { + write_error_chain_with_options(err, ErrorOptions::default()) +} + +/// Formats an error or warning chain with custom options. +/// +/// Each hint is rendered on its own line, prefixed with the styled `hint:` label. +pub fn write_error_chain_with_options( + err: &dyn Error, + options: ErrorOptions<'_, C, W>, +) -> fmt::Result { + let ErrorOptions { + level, + color, + hints, + width_override, + mut stream, + } = options; + let width = get_wrap_width(width_override); + + let main_msg = err.to_string(); + let main_padding = " ".repeat(level.len() + 2); + let wrapped_main = wrap_text(&main_msg, width, &main_padding, &main_padding); + writeln!( + &mut stream, + "{}{} {}", + level.as_ref().color(color).bold(), + ":".bold(), + wrapped_main.trim() + )?; + + for source in iter::successors(err.source(), |&err| err.source()) { + let msg = source.to_string(); + let padding = " "; + let cause = "Caused by"; + let child_padding = " ".repeat(padding.len() + cause.len() + 2); + + let wrapped = wrap_text(&msg, width, "", &child_padding); + + let mut lines = wrapped.lines(); + if let Some(first) = lines.next() { + writeln!( + &mut stream, + "{}{}: {}", + padding, + cause.color(color).bold(), + first.trim() + )?; + for line in lines { + if line.trim().is_empty() { + writeln!(&mut stream)?; + } else { + writeln!(&mut stream, "{line}")?; + } + } + } + } + + for hint in hints { + writeln!(&mut stream, "\n{HintPrefix} {hint}")?; + } + + Ok(()) +} + #[cfg(test)] mod tests { - use super::{ErrorWithHints, HintPrefix, Hints}; + use anyhow::anyhow; + use indoc::indoc; + use insta::assert_snapshot; + use owo_colors::AnsiColors; + + use super::{ErrorOptions, ErrorWithHints, HintPrefix, Hints, write_error_chain_with_options}; #[test] fn extend_deduplicates_matching_hints() { @@ -157,4 +311,279 @@ mod tests { "error" ); } + + #[test] + fn test_error_wrapping_with_columns() { + #[derive(Debug, thiserror::Error)] + #[error( + "Because fiasobfhuasbf was not found in the package registry and you require fiasobfhuasbf, we can conclude that your requirements are unsatisfiable." + )] + struct Inner; + + #[derive(Debug, thiserror::Error)] + #[error("No solution found when resolving dependencies")] + struct Outer { + #[source] + source: Inner, + } + + let error = Outer { source: Inner }; + let mut output = String::new(); + write_error_chain_with_options( + &error, + ErrorOptions::default() + .with_width_override(80) + .with_stream(&mut output), + ) + .unwrap(); + let output = anstream::adapter::strip_str(&output); + + assert_snapshot!(output, @r" + error: No solution found when resolving dependencies + Caused by: Because fiasobfhuasbf was not found in the package registry and you require + fiasobfhuasbf, we can conclude that your requirements are + unsatisfiable. + "); + } + + #[test] + fn test_error_chain_with_cause() { + #[derive(Debug, thiserror::Error)] + #[error("Permission denied")] + struct Inner; + + #[derive(Debug, thiserror::Error)] + #[error("Failed to write file")] + struct Outer { + #[source] + source: Inner, + } + + let error = Outer { source: Inner }; + let mut output = String::new(); + write_error_chain_with_options(&error, ErrorOptions::default().with_stream(&mut output)) + .unwrap(); + assert_snapshot!(format!("{output:?}"), @r#""\u{1b}[1m\u{1b}[31merror\u{1b}[39m\u{1b}[0m\u{1b}[1m:\u{1b}[0m Failed to write file\n \u{1b}[1m\u{1b}[31mCaused by\u{1b}[39m\u{1b}[0m: Permission denied\n""#); + let output = anstream::adapter::strip_str(&output); + + assert_snapshot!(output, @r" + error: Failed to write file + Caused by: Permission denied + "); + } + + #[test] + fn format_with_custom_level() { + let error = anyhow!("Failed to create registry entry"); + let mut output = String::new(); + write_error_chain_with_options( + error.as_ref(), + ErrorOptions::default() + .with_level("warning") + .with_color(AnsiColors::Yellow) + .with_stream(&mut output), + ) + .unwrap(); + let output = anstream::adapter::strip_str(&output); + + assert_snapshot!(output, @"warning: Failed to create registry entry +"); + } + + #[test] + fn test_no_hyphenation() { + #[derive(Debug, thiserror::Error)] + #[error( + "Failed to download package from https://files.pythonhosted.org/packages/verylongpackagename" + )] + struct LongWord; + + let error = LongWord; + let mut output = String::new(); + write_error_chain_with_options( + &error, + ErrorOptions::default() + .with_width_override(50) + .with_stream(&mut output), + ) + .unwrap(); + let output = anstream::adapter::strip_str(&output); + assert_snapshot!(output, @r" + error: Failed to download package from + https://files.pythonhosted.org/packages/verylongpackagename + "); + } + + #[test] + fn test_long_words_not_broken() { + #[derive(Debug, thiserror::Error)] + #[error( + "The package supercalifragilisticexpialidocious-extraordinarily-long-name was not found" + )] + struct VeryLongWord; + + let error = VeryLongWord; + let mut output = String::new(); + write_error_chain_with_options( + &error, + ErrorOptions::default() + .with_width_override(40) + .with_stream(&mut output), + ) + .unwrap(); + let output = anstream::adapter::strip_str(&output); + assert_snapshot!(output, @r" + error: The package + supercalifragilisticexpialidocious-extraordinarily-long-name + was not found + "); + } + + #[test] + fn test_multiple_error_sources() { + #[derive(Debug, thiserror::Error)] + #[error("Network connection timeout after multiple retry attempts")] + struct DeepError; + + #[derive(Debug, thiserror::Error)] + #[error("Failed to fetch package metadata from registry")] + struct MiddleError { + #[source] + source: DeepError, + } + + #[derive(Debug, thiserror::Error)] + #[error("Unable to resolve package dependencies")] + struct TopError { + #[source] + source: MiddleError, + } + + let error = TopError { + source: MiddleError { source: DeepError }, + }; + let mut output = String::new(); + write_error_chain_with_options( + &error, + ErrorOptions::default() + .with_width_override(60) + .with_stream(&mut output), + ) + .unwrap(); + let output = anstream::adapter::strip_str(&output); + assert_snapshot!(output, @r" + error: Unable to resolve package dependencies + Caused by: Failed to fetch package metadata from registry + Caused by: Network connection timeout after multiple retry attempts + "); + } + + #[test] + fn test_multiline_main_message_wraps_each_line() { + #[derive(Debug, thiserror::Error)] + #[error( + "There is no command `foobar` for `uv`. Did you mean one of:\n auth\n run\n init" + )] + struct Suggestions; + + let error = Suggestions; + let mut output = String::new(); + write_error_chain_with_options( + &error, + ErrorOptions::default() + .with_width_override(50) + .with_stream(&mut output), + ) + .unwrap(); + let output = anstream::adapter::strip_str(&output); + + assert_snapshot!(output, @r" + error: There is no command `foobar` for `uv`. Did + you mean one of: + auth + run + init + "); + } + + #[test] + fn test_wrap_only_on_ascii_space() { + #[derive(Debug, thiserror::Error)] + #[error("Path /usr/local/lib/python3.12/site-packages not found in filesystem hierarchy")] + struct SpecialChars; + + let error = SpecialChars; + let mut output = String::new(); + write_error_chain_with_options( + &error, + ErrorOptions::default() + .with_width_override(50) + .with_stream(&mut output), + ) + .unwrap(); + let output = anstream::adapter::strip_str(&output); + assert_snapshot!(output, @r" + error: Path /usr/local/lib/python3.12/site-packages + not found in filesystem hierarchy + "); + } + + #[test] + fn format_with_hints() { + let err = anyhow!("Permission denied").context("Failed to fetch package"); + + let hints = [ + "Try running with `--verbose` for more information.".to_string(), + "Try running without --offline.".to_string(), + ] + .into_iter() + .collect(); + + let mut rendered = String::new(); + write_error_chain_with_options( + err.as_ref(), + ErrorOptions::default() + .with_hints(hints) + .with_stream(&mut rendered), + ) + .unwrap(); + let rendered = anstream::adapter::strip_str(&rendered); + + assert_snapshot!(rendered, @r" + error: Failed to fetch package + Caused by: Permission denied + + hint: Try running with `--verbose` for more information. + + hint: Try running without --offline. + "); + } + + #[test] + fn format_multiline_message() { + let err_middle = indoc! {"Failed to fetch https://example.com/upload/python3.13.tar.zst + Server says: This endpoint only support POST requests. + + For downloads, please refer to https://example.com/download/python3.13.tar.zst"}; + let err = anyhow!("Caused By: HTTP Error 400") + .context(err_middle) + .context("Failed to download Python 3.12"); + + let mut rendered = String::new(); + write_error_chain_with_options( + err.as_ref(), + ErrorOptions::default().with_stream(&mut rendered), + ) + .unwrap(); + let rendered = anstream::adapter::strip_str(&rendered); + + assert_snapshot!(rendered, @r" + error: Failed to download Python 3.12 + Caused by: Failed to fetch https://example.com/upload/python3.13.tar.zst + Server says: This endpoint only support POST requests. + + For downloads, please refer to https://example.com/download/python3.13.tar.zst + Caused by: Caused By: HTTP Error 400 + "); + } } diff --git a/crates/uv-errors/src/line_wrap.rs b/crates/uv-errors/src/line_wrap.rs new file mode 100644 index 0000000000000..15e74810bfdc6 --- /dev/null +++ b/crates/uv-errors/src/line_wrap.rs @@ -0,0 +1,81 @@ +use uv_static::EnvVars; + +/// Checks if line wrapping should be enabled. +/// +/// Returns `false` if `UV_NO_WRAP` is set. +fn should_wrap_lines() -> bool { + std::env::var_os(EnvVars::UV_NO_WRAP).is_none() +} + +/// Gets the terminal width for wrapping. +/// +/// Uses `width_override`, then the `COLUMNS` environment variable, and finally attempts to detect +/// the width from the terminal. Returns `None` if no width is available. +pub(crate) fn get_wrap_width(width_override: Option) -> Option { + if !should_wrap_lines() { + return None; + } + + if let Some(width) = width_override { + return Some(width); + } + + if let Ok(cols) = std::env::var(EnvVars::COLUMNS) { + if let Ok(width) = cols.parse::() { + return Some(width); + } + } + + if let Some((terminal_size::Width(width), _)) = terminal_size::terminal_size() { + return Some(width as usize); + } + + None +} + +/// Wraps text at word boundaries with proper indentation. +/// +/// Based on miette's `wrap()` implementation from: +/// +pub(crate) fn wrap_text( + text: &str, + width: Option, + initial_indent: &str, + subsequent_indent: &str, +) -> String { + let Some(width) = width else { + return format!("{initial_indent}{text}"); + }; + + let options = textwrap::Options::new(width) + .initial_indent(initial_indent) + .subsequent_indent(subsequent_indent) + .break_words(false) + .word_separator(textwrap::WordSeparator::AsciiSpace) + .word_splitter(textwrap::WordSplitter::NoHyphenation); + + let mut wrapped = String::with_capacity(text.len()); + + for (index, line) in text.split_terminator('\n').enumerate() { + if index > 0 { + wrapped.push('\n'); + } + + if line.is_empty() { + continue; + } + + // Preserve authored line breaks and wrap each line independently. Hanging indentation is + // only for continuation lines created by wrapping, not for lines already in the message. + wrapped.push_str(&textwrap::fill( + line, + if index == 0 { + options.clone() + } else { + options.clone().initial_indent("") + }, + )); + } + + wrapped +} diff --git a/crates/uv-extract/Cargo.toml b/crates/uv-extract/Cargo.toml index 4899945586df4..ad8a06ca706b7 100644 --- a/crates/uv-extract/Cargo.toml +++ b/crates/uv-extract/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-extract" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-extract/README.md b/crates/uv-extract/README.md index 8e26102b04153..8018398b14a72 100644 --- a/crates/uv-extract/README.md +++ b/crates/uv-extract/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-extract). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-extract). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-fastid/Cargo.toml b/crates/uv-fastid/Cargo.toml index a69c066828db1..7fd80c93ee803 100644 --- a/crates/uv-fastid/Cargo.toml +++ b/crates/uv-fastid/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "uv-fastid" description = "This is an internal component crate of uv" -version = "0.0.49" +version = "0.0.50" edition.workspace = true rust-version.workspace = true homepage.workspace = true diff --git a/crates/uv-fastid/README.md b/crates/uv-fastid/README.md index 493bd889e53d8..ac0cfe6c80f2f 100644 --- a/crates/uv-fastid/README.md +++ b/crates/uv-fastid/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-fastid). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-fastid). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-flags/Cargo.toml b/crates/uv-flags/Cargo.toml index d472ef44efdd2..6070e39225367 100644 --- a/crates/uv-flags/Cargo.toml +++ b/crates/uv-flags/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-flags" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-flags/README.md b/crates/uv-flags/README.md index 847b4fbb58385..2e489487401f7 100644 --- a/crates/uv-flags/README.md +++ b/crates/uv-flags/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-flags). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-flags). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-fs/Cargo.toml b/crates/uv-fs/Cargo.toml index 0b0677e475c50..86258f733c8d7 100644 --- a/crates/uv-fs/Cargo.toml +++ b/crates/uv-fs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-fs" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-fs/README.md b/crates/uv-fs/README.md index a538e644bb391..6172b514ad6d1 100644 --- a/crates/uv-fs/README.md +++ b/crates/uv-fs/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-fs). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-fs). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-git-types/Cargo.toml b/crates/uv-git-types/Cargo.toml index 1f31e2c5224ed..7707c9974ce83 100644 --- a/crates/uv-git-types/Cargo.toml +++ b/crates/uv-git-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-git-types" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-git-types/README.md b/crates/uv-git-types/README.md index 7f75ac9ead2b8..83766c56d9e53 100644 --- a/crates/uv-git-types/README.md +++ b/crates/uv-git-types/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-git-types). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-git-types). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-git/Cargo.toml b/crates/uv-git/Cargo.toml index f5f1fcd8747f0..fe802cc6092fe 100644 --- a/crates/uv-git/Cargo.toml +++ b/crates/uv-git/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-git" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-git/README.md b/crates/uv-git/README.md index d6d6df70a8379..fe0fcbee243ce 100644 --- a/crates/uv-git/README.md +++ b/crates/uv-git/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-git). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-git). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-git/src/lib.rs b/crates/uv-git/src/lib.rs index 2178b99c5fbb2..bb9ec79fece14 100644 --- a/crates/uv-git/src/lib.rs +++ b/crates/uv-git/src/lib.rs @@ -1,7 +1,8 @@ pub use crate::credentials::{store_credentials, store_credentials_from_url}; pub use crate::git::{GIT, GIT_LFS, GitError}; pub use crate::resolver::{ - GitResolver, GitResolverError, RepositoryReference, ResolvedRepositoryReference, + GitHttpSettings, GitResolver, GitResolverError, RepositoryReference, + ResolvedRepositoryReference, }; pub use crate::source::{Fetch, GitSource, Reporter}; diff --git a/crates/uv-git/src/resolver.rs b/crates/uv-git/src/resolver.rs index 4e3de5aeb0b26..fb36d67fd45b7 100644 --- a/crates/uv-git/src/resolver.rs +++ b/crates/uv-git/src/resolver.rs @@ -36,6 +36,29 @@ pub enum GitResolverError { ReqwestMiddleware(#[from] reqwest_middleware::Error), } +/// HTTP settings for fetching a Git repository. +#[derive(Debug, Clone, Copy, Default)] +pub struct GitHttpSettings { + disable_ssl: bool, + offline: bool, +} + +impl GitHttpSettings { + /// Configure whether certificate verification should be disabled. + #[must_use] + pub fn with_disabled_ssl(mut self, disable_ssl: bool) -> Self { + self.disable_ssl = disable_ssl; + self + } + + /// Configure whether network access should be disabled. + #[must_use] + pub fn with_offline(mut self, offline: bool) -> Self { + self.offline = offline; + self + } +} + /// A resolver for Git repositories. #[derive(Default, Clone)] pub struct GitResolver(Arc>); @@ -146,8 +169,7 @@ impl GitResolver { pub async fn fetch( &self, url: &GitUrl, - disable_ssl: bool, - offline: bool, + http_settings: GitHttpSettings, cache: PathBuf, reporter: Option>, ) -> Result { @@ -178,13 +200,14 @@ impl GitResolver { // Fetch the Git repository. let source = if let Some(reporter) = reporter { - GitSource::new(url.as_ref().clone(), cache, offline).with_reporter(reporter) + GitSource::new(url.as_ref().clone(), cache, http_settings.offline) + .with_reporter(reporter) } else { - GitSource::new(url.as_ref().clone(), cache, offline) + GitSource::new(url.as_ref().clone(), cache, http_settings.offline) }; // If necessary, disable SSL. - let source = if disable_ssl { + let source = if http_settings.disable_ssl { source.dangerous() } else { source diff --git a/crates/uv-globfilter/Cargo.toml b/crates/uv-globfilter/Cargo.toml index 8986d873fd82f..9bde8546118a7 100644 --- a/crates/uv-globfilter/Cargo.toml +++ b/crates/uv-globfilter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-globfilter" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" readme = "README.md" edition = { workspace = true } diff --git a/crates/uv-install-wheel/Cargo.toml b/crates/uv-install-wheel/Cargo.toml index 2c80bc376ef8d..fe7c8ab018306 100644 --- a/crates/uv-install-wheel/Cargo.toml +++ b/crates/uv-install-wheel/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-install-wheel" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" keywords = ["wheel", "python"] diff --git a/crates/uv-install-wheel/README.md b/crates/uv-install-wheel/README.md index 1e74497ff72f6..8b96327b6e760 100644 --- a/crates/uv-install-wheel/README.md +++ b/crates/uv-install-wheel/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-install-wheel). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-install-wheel). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-install-wheel/src/wheel.rs b/crates/uv-install-wheel/src/wheel.rs index afe8ba05d5b97..39a628215ab33 100644 --- a/crates/uv-install-wheel/src/wheel.rs +++ b/crates/uv-install-wheel/src/wheel.rs @@ -160,7 +160,11 @@ fn get_script_executable(python_executable: &Path, is_gui: bool) -> PathBuf { } } -const RESERVED_SCRIPT_NAMES_ERROR: &[&str; 3] = &["python", "pythonw", "python3"]; +const RESERVED_SCRIPT_NAMES_ERROR: &[&str; 7] = &[ + "python", "pythonw", "python3", "graalpy", "pypy", "pypy2", "pypy3", +]; +const RESERVED_VERSIONED_SCRIPT_NAME_PREFIX_ERROR: &str = "python3."; +const RESERVED_FREE_THREADED_SCRIPT_NAME_PREFIXES_ERROR: &[&str; 2] = &["python3.", "pythonw3."]; const RESERVED_SCRIPT_NAMES_WARN: &[&str; 2] = &["activate", "activate_this.py"]; /// A form of [`Script`] guaranteed by [`ValidatedScript::try_from_script`] to be constrained to @@ -193,25 +197,32 @@ impl<'script> ValidatedScript<'script> { ); } - if RESERVED_SCRIPT_NAMES_ERROR.contains(&name.as_str()) - || name - .strip_prefix("python3.") - .is_some_and(|suffix| suffix.parse::().is_ok()) + // Reserve launcher basenames emitted by `uv venv` across supported platforms. + // Apply the Windows launcher normalization before checking so wheel validity is portable. + // FIXME: What are the in-reality rules here for name normalization? + let normalized_name = name.strip_suffix(".py").unwrap_or(name.as_str()); + if RESERVED_SCRIPT_NAMES_ERROR.contains(&normalized_name) + || normalized_name + .strip_prefix(RESERVED_VERSIONED_SCRIPT_NAME_PREFIX_ERROR) + .is_some_and(|minor| minor.parse::().is_ok()) + || RESERVED_FREE_THREADED_SCRIPT_NAME_PREFIXES_ERROR + .iter() + .any(|prefix| { + normalized_name + .strip_prefix(prefix) + .and_then(|minor| minor.strip_suffix('t')) + .is_some_and(|minor| minor.parse::().is_ok()) + }) { return Err(Error::ReservedScriptName { - reserved: name, + reserved: normalized_name.to_string(), declared: script.name.clone(), }); } let path = if cfg!(windows) { // On Windows we actually build an `.exe` wrapper. - let name = name - // FIXME: What are the in-reality rules here for names? - .strip_suffix(".py") - .unwrap_or(&name) - .to_string() - + std::env::consts::EXE_SUFFIX; + let name = normalized_name.to_string() + std::env::consts::EXE_SUFFIX; layout.scheme.scripts.join(name) } else { diff --git a/crates/uv-installer/Cargo.toml b/crates/uv-installer/Cargo.toml index fafab5b1a0eec..35c1a51a7e47f 100644 --- a/crates/uv-installer/Cargo.toml +++ b/crates/uv-installer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-installer" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-installer/README.md b/crates/uv-installer/README.md index e81b6e5de4d22..dec8a0edabc78 100644 --- a/crates/uv-installer/README.md +++ b/crates/uv-installer/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-installer). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-installer). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-keyring/Cargo.toml b/crates/uv-keyring/Cargo.toml index 15b76f651de2d..463a152dd3443 100644 --- a/crates/uv-keyring/Cargo.toml +++ b/crates/uv-keyring/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-keyring" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-logging/Cargo.toml b/crates/uv-logging/Cargo.toml index 81bfbb36a5ae1..0fee657369661 100644 --- a/crates/uv-logging/Cargo.toml +++ b/crates/uv-logging/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-logging" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-logging/README.md b/crates/uv-logging/README.md index 6a8c0b15501b0..e8559c692c861 100644 --- a/crates/uv-logging/README.md +++ b/crates/uv-logging/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-logging). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-logging). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-macros/Cargo.toml b/crates/uv-macros/Cargo.toml index 1d90b5f47b2b2..59ad227f7906a 100644 --- a/crates/uv-macros/Cargo.toml +++ b/crates/uv-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-macros" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-macros/README.md b/crates/uv-macros/README.md index 1c2ad3b915fc3..279cb8882aba2 100644 --- a/crates/uv-macros/README.md +++ b/crates/uv-macros/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-macros). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-macros). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-metadata/Cargo.toml b/crates/uv-metadata/Cargo.toml index b75a855709473..2a1db0b72be9e 100644 --- a/crates/uv-metadata/Cargo.toml +++ b/crates/uv-metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-metadata" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-metadata/README.md b/crates/uv-metadata/README.md index 57bd29ede2e66..6e6db981c09e8 100644 --- a/crates/uv-metadata/README.md +++ b/crates/uv-metadata/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-metadata). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-metadata). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-netrc/Cargo.toml b/crates/uv-netrc/Cargo.toml index 9c897c167157a..5fed4cd24bb1d 100644 --- a/crates/uv-netrc/Cargo.toml +++ b/crates/uv-netrc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-netrc" -version = "0.0.49" +version = "0.0.50" description = "A vendored netrc parser for uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-normalize/Cargo.toml b/crates/uv-normalize/Cargo.toml index 3e138889b6b5b..0bb58aa3c8b61 100644 --- a/crates/uv-normalize/Cargo.toml +++ b/crates/uv-normalize/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-normalize" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-normalize/README.md b/crates/uv-normalize/README.md index 16281017dfcaa..5789601cf18cc 100644 --- a/crates/uv-normalize/README.md +++ b/crates/uv-normalize/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-normalize). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-normalize). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-once-map/Cargo.toml b/crates/uv-once-map/Cargo.toml index ef3d4e0340aa0..ceb2f2c17c482 100644 --- a/crates/uv-once-map/Cargo.toml +++ b/crates/uv-once-map/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-once-map" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-once-map/README.md b/crates/uv-once-map/README.md index 60b4a29d89fe3..37ac8ec92d140 100644 --- a/crates/uv-once-map/README.md +++ b/crates/uv-once-map/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-once-map). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-once-map). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-options-metadata/Cargo.toml b/crates/uv-options-metadata/Cargo.toml index 083fcbd73ac90..91fd858643a6d 100644 --- a/crates/uv-options-metadata/Cargo.toml +++ b/crates/uv-options-metadata/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-options-metadata" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-options-metadata/README.md b/crates/uv-options-metadata/README.md index a2edf19d2c937..e92796de1a6d1 100644 --- a/crates/uv-options-metadata/README.md +++ b/crates/uv-options-metadata/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-options-metadata). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-options-metadata). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-pep440/Cargo.toml b/crates/uv-pep440/Cargo.toml index 50384e4b153ca..bbaf5e38037be 100644 --- a/crates/uv-pep440/Cargo.toml +++ b/crates/uv-pep440/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-pep440" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" license = "Apache-2.0 OR BSD-2-Clause" include = ["/src", "Changelog.md", "License-Apache", "License-BSD", "Readme.md", "pyproject.toml"] diff --git a/crates/uv-pep440/README.md b/crates/uv-pep440/README.md index 20fc6c36d0b4f..35bb69bbd112b 100644 --- a/crates/uv-pep440/README.md +++ b/crates/uv-pep440/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-pep440). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-pep440). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-pep508/Cargo.toml b/crates/uv-pep508/Cargo.toml index 4c84adaa102c3..2d30629943294 100644 --- a/crates/uv-pep508/Cargo.toml +++ b/crates/uv-pep508/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-pep508" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" include = ["/src", "Changelog.md", "License-Apache", "License-BSD", "Readme.md", "pyproject.toml"] license = "Apache-2.0 OR BSD-2-Clause" diff --git a/crates/uv-pep508/README.md b/crates/uv-pep508/README.md index 6b54b396e2047..30efd3ba17ec9 100644 --- a/crates/uv-pep508/README.md +++ b/crates/uv-pep508/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-pep508). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-pep508). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-pep508/src/marker/algebra.rs b/crates/uv-pep508/src/marker/algebra.rs index e9a138ff13104..214330b15da5e 100644 --- a/crates/uv-pep508/src/marker/algebra.rs +++ b/crates/uv-pep508/src/marker/algebra.rs @@ -558,27 +558,46 @@ impl InternerGuard<'_> { /// `((os_name == ... and extra == foo) or (sys_platform == ... and extra != foo))`, /// this would return a marker /// `os_name == ... or sys_platform == ...`. - pub(crate) fn without_extras(&mut self, mut i: NodeId) -> NodeId { + pub(crate) fn without_extras(&mut self, i: NodeId) -> NodeId { + let mut cache = FxHashMap::default(); + self.without_extras_cached(i, &mut cache) + } + + fn without_extras_cached( + &mut self, + mut i: NodeId, + cache: &mut FxHashMap, + ) -> NodeId { if matches!(i, NodeId::TRUE | NodeId::FALSE) { return i; } + if let Some(&cached) = cache.get(&i) { + return cached; + } + + let original = i; let parent = i; let node = self.shared.node(i); - if matches!(node.var, Variable::Extra(_)) { + let result = if matches!(node.var, Variable::Extra(_)) { i = NodeId::FALSE; for child in node.children.nodes() { i = self.or(i, child.negate(parent)); } if i.is_true() { - return NodeId::TRUE; + NodeId::TRUE + } else { + self.without_extras_cached(i, cache) } - self.without_extras(i) } else { // Restrict all nodes recursively. - let children = node.children.map(i, |node| self.without_extras(node)); + let children = node + .children + .map(i, |node| self.without_extras_cached(node, cache)); self.create_node(node.var.clone(), children) - } + }; + cache.insert(original, result); + result } /// Returns a new tree where the only nodes remaining are `extra` nodes. diff --git a/crates/uv-performance-memory-allocator/Cargo.toml b/crates/uv-performance-memory-allocator/Cargo.toml index d240fc7710f2a..87fa3108afe31 100644 --- a/crates/uv-performance-memory-allocator/Cargo.toml +++ b/crates/uv-performance-memory-allocator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-performance-memory-allocator" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-performance-memory-allocator/README.md b/crates/uv-performance-memory-allocator/README.md index a44ace88c0087..d1b11c2b15175 100644 --- a/crates/uv-performance-memory-allocator/README.md +++ b/crates/uv-performance-memory-allocator/README.md @@ -5,9 +5,9 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The source can be found -[here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-performance-memory-allocator). +[here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-performance-memory-allocator). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-platform-tags/Cargo.toml b/crates/uv-platform-tags/Cargo.toml index 1bda4405bbb46..aae460512ba81 100644 --- a/crates/uv-platform-tags/Cargo.toml +++ b/crates/uv-platform-tags/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-platform-tags" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-platform-tags/README.md b/crates/uv-platform-tags/README.md index 08b23c3497b5b..d65d06d247f04 100644 --- a/crates/uv-platform-tags/README.md +++ b/crates/uv-platform-tags/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-platform-tags). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-platform-tags). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-platform/Cargo.toml b/crates/uv-platform/Cargo.toml index 4f15e2e7107c2..4f2416d748dde 100644 --- a/crates/uv-platform/Cargo.toml +++ b/crates/uv-platform/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-platform" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-platform/README.md b/crates/uv-platform/README.md index 6cee2786aa19c..1c015d18089f6 100644 --- a/crates/uv-platform/README.md +++ b/crates/uv-platform/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-platform). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-platform). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-preview/Cargo.toml b/crates/uv-preview/Cargo.toml index 290fdbe7266b1..7effd6481513f 100644 --- a/crates/uv-preview/Cargo.toml +++ b/crates/uv-preview/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-preview" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-preview/README.md b/crates/uv-preview/README.md index fe6b87d72a268..ac12cdd2418fb 100644 --- a/crates/uv-preview/README.md +++ b/crates/uv-preview/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-preview). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-preview). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-preview/src/lib.rs b/crates/uv-preview/src/lib.rs index 83b8c60aa68dd..1ffefcf86106e 100644 --- a/crates/uv-preview/src/lib.rs +++ b/crates/uv-preview/src/lib.rs @@ -219,7 +219,7 @@ pub mod test { } #[bitflags] -#[repr(u32)] +#[repr(u64)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PreviewFeature { PythonInstallDefault = 1 << 0, @@ -254,6 +254,7 @@ pub enum PreviewFeature { AzureEndpoint = 1 << 29, TomlBackwardsCompatibility = 1 << 30, MalwareCheck = 1 << 31, + VenvSafeClear = 1 << 32, } impl PreviewFeature { @@ -292,6 +293,7 @@ impl PreviewFeature { Self::AzureEndpoint => "azure-endpoint", Self::TomlBackwardsCompatibility => "toml-backwards-compatibility", Self::MalwareCheck => "malware-check", + Self::VenvSafeClear => "venv-safe-clear", } } } @@ -343,6 +345,7 @@ impl FromStr for PreviewFeature { "azure-endpoint" => Self::AzureEndpoint, "toml-backwards-compatibility" => Self::TomlBackwardsCompatibility, "malware-check" => Self::MalwareCheck, + "venv-safe-clear" => Self::VenvSafeClear, _ => return Err(PreviewFeatureParseError), }) } @@ -602,6 +605,7 @@ mod tests { "toml-backwards-compatibility" ); assert_eq!(PreviewFeature::MalwareCheck.as_str(), "malware-check"); + assert_eq!(PreviewFeature::VenvSafeClear.as_str(), "venv-safe-clear"); } #[test] diff --git a/crates/uv-publish/Cargo.toml b/crates/uv-publish/Cargo.toml index 8c51f34e72416..7067168fec18f 100644 --- a/crates/uv-publish/Cargo.toml +++ b/crates/uv-publish/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-publish" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } @@ -16,6 +16,7 @@ doctest = false uv-auth = { workspace = true } uv-cache = { workspace = true } uv-client = { workspace = true } +uv-errors = { workspace = true } uv-configuration = { workspace = true } uv-distribution-filename = { workspace = true } uv-distribution-types = { workspace = true } diff --git a/crates/uv-publish/README.md b/crates/uv-publish/README.md index c4b54a2a20e41..a3c438e659f91 100644 --- a/crates/uv-publish/README.md +++ b/crates/uv-publish/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-publish). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-publish). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-publish/src/lib.rs b/crates/uv-publish/src/lib.rs index f49f003ee94b4..53ad1a64bcb57 100644 --- a/crates/uv-publish/src/lib.rs +++ b/crates/uv-publish/src/lib.rs @@ -1179,6 +1179,22 @@ impl FormMetadata { .find(|hash| hash.algorithm == HashAlgorithm::Blake2b) .unwrap(); + let metadata = metadata(file, filename).await?; + + Ok(Self::from_metadata( + metadata, + filename, + sha256_hash, + blake2b_hash, + )) + } + + fn from_metadata( + metadata: Metadata23, + filename: &DistFilename, + sha256_hash: &HashDigest, + blake2b_hash: &HashDigest, + ) -> Self { let Metadata23 { metadata_version, name, @@ -1207,8 +1223,10 @@ impl FormMetadata { requires_external, project_urls, provides_extra, + import_names, + import_namespaces, dynamic, - } = metadata(file, filename).await?; + } = metadata; let mut form_metadata = vec![ (":action", "file_upload".to_string()), @@ -1269,10 +1287,12 @@ impl FormMetadata { add_vec("project_urls", project_urls.to_vec_str()); add_vec("provides_dist", provides_dist); add_vec("provides_extra", provides_extra); + add_vec("import_names", import_names); + add_vec("import_namespaces", import_namespaces); add_vec("requires_dist", requires_dist); add_vec("requires_external", requires_external); - Ok(Self(form_metadata)) + Self(form_metadata) } /// Returns an iterator over the metadata fields. @@ -1512,6 +1532,7 @@ mod tests { use uv_auth::Credentials; use uv_client::{AuthIntegration, BaseClientBuilder, RedirectPolicy}; use uv_distribution_filename::DistFilename; + use uv_pypi_types::{HashDigest, Metadata23}; use uv_redacted::DisplaySafeUrl; use crate::{ @@ -1519,8 +1540,7 @@ mod tests { group_files, upload, }; use tokio::sync::Semaphore; - use uv_warnings::owo_colors::AnsiColors; - use uv_warnings::write_error_chain; + use uv_errors::{ErrorOptions, write_error_chain_with_options}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -1859,6 +1879,45 @@ mod tests { } } + #[test] + fn form_metadata_import_names() { + let filename = DistFilename::try_from_normalized_filename("pkg-1.0.0.tar.gz").unwrap(); + let sha256_hash: HashDigest = "sha256:0123".parse().unwrap(); + let blake2b_hash: HashDigest = "blake2b:4567".parse().unwrap(); + let metadata = Metadata23 { + metadata_version: "2.5".to_string(), + name: "pkg".to_string(), + version: "1.0.0".to_string(), + requires_python: Some(">=3.12".to_string()), + import_names: vec!["spam".to_string(), "spam.eggs; private".to_string()], + import_namespaces: vec!["zope".to_string()], + ..Default::default() + }; + + let form_metadata = + FormMetadata::from_metadata(metadata, &filename, &sha256_hash, &blake2b_hash); + let formatted_metadata = form_metadata + .iter() + .map(|(key, value)| format!("{key}: {value}")) + .join("\n"); + + assert_snapshot!(formatted_metadata, @r###" + :action: file_upload + sha256_digest: 0123 + blake2_256_digest: 4567 + protocol_version: 1 + metadata_version: 2.5 + name: pkg + version: 1.0.0 + filetype: sdist + pyversion: source + requires_python: >=3.12 + import_names: spam + import_names: spam.eggs; private + import_namespaces: zope + "###); + } + /// Snapshot the data we send for an upload request for a source distribution. #[tokio::test] async fn upload_request_source_dist() { @@ -2190,7 +2249,8 @@ mod tests { let err = mock_server_upload(&mock_server).await.unwrap_err(); let mut capture = String::new(); - write_error_chain(&err, &mut capture, "error", AnsiColors::Red).unwrap(); + write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture)) + .unwrap(); let capture = capture.replace(&mock_server.uri(), "[SERVER]"); let capture = anstream::adapter::strip_str(&capture); @@ -2218,7 +2278,8 @@ mod tests { let err = mock_server_upload(&mock_server).await.unwrap_err(); let mut capture = String::new(); - write_error_chain(&err, &mut capture, "error", AnsiColors::Red).unwrap(); + write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture)) + .unwrap(); let capture = capture.replace(&mock_server.uri(), "[SERVER]"); let capture = anstream::adapter::strip_str(&capture); @@ -2251,7 +2312,8 @@ mod tests { let err = mock_server_upload(&mock_server).await.unwrap_err(); let mut capture = String::new(); - write_error_chain(&err, &mut capture, "error", AnsiColors::Red).unwrap(); + write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture)) + .unwrap(); let capture = capture.replace(&mock_server.uri(), "[SERVER]"); let capture = anstream::adapter::strip_str(&capture); @@ -2287,7 +2349,8 @@ mod tests { let err = mock_server_upload(&mock_server).await.unwrap_err(); let mut capture = String::new(); - write_error_chain(&err, &mut capture, "error", AnsiColors::Red).unwrap(); + write_error_chain_with_options(&err, ErrorOptions::default().with_stream(&mut capture)) + .unwrap(); let capture = capture.replace(&mock_server.uri(), "[SERVER]"); let capture = anstream::adapter::strip_str(&capture); diff --git a/crates/uv-pypi-types/Cargo.toml b/crates/uv-pypi-types/Cargo.toml index 067158670f2db..cb9645b2dc8fb 100644 --- a/crates/uv-pypi-types/Cargo.toml +++ b/crates/uv-pypi-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-pypi-types" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-pypi-types/README.md b/crates/uv-pypi-types/README.md index 7d464399dc19c..745f745b7c7a6 100644 --- a/crates/uv-pypi-types/README.md +++ b/crates/uv-pypi-types/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-pypi-types). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-pypi-types). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-pypi-types/src/lib.rs b/crates/uv-pypi-types/src/lib.rs index 58f77175332e8..8bac08563b90e 100644 --- a/crates/uv-pypi-types/src/lib.rs +++ b/crates/uv-pypi-types/src/lib.rs @@ -6,6 +6,7 @@ pub use identifier::*; pub use lenient_requirement::*; pub use marker_environment::*; pub use metadata::*; +pub use module_name::*; pub use parsed_url::*; pub use project_status::*; pub use scheme::*; @@ -20,6 +21,7 @@ mod identifier; mod lenient_requirement; mod marker_environment; mod metadata; +mod module_name; mod parsed_url; mod project_status; mod scheme; diff --git a/crates/uv-pypi-types/src/metadata/metadata23.rs b/crates/uv-pypi-types/src/metadata/metadata23.rs index d02d9bed758a4..807f4fc9aea2f 100644 --- a/crates/uv-pypi-types/src/metadata/metadata23.rs +++ b/crates/uv-pypi-types/src/metadata/metadata23.rs @@ -1,4 +1,5 @@ //! Vendored from +use std::collections::BTreeSet; use std::fmt::Display; use std::fmt::Write; use std::str; @@ -9,13 +10,13 @@ use indexmap::IndexMap; use crate::MetadataError; use crate::metadata::Headers; -/// Code Metadata 2.3 as specified in +/// Core Metadata 2.x as specified in /// . #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub struct Metadata23 { - /// Version of the file format; legal values are `1.0`, `1.1`, `1.2`, `2.1`, `2.2`, `2.3` and - /// `2.4`. + /// Version of the file format; legal values are `1.0`, `1.1`, `1.2`, `2.1`, `2.2`, `2.3`, + /// `2.4`, and `2.5`. pub metadata_version: String, /// The name of the distribution. pub name: String, @@ -102,6 +103,14 @@ pub struct Metadata23 { /// May be used to make a dependency conditional on whether the optional feature has been /// requested. pub provides_extra: Vec, + /// Import names exclusively provided by the project. + /// + /// Introduced by PEP 794, requires metadata version 2.5. + pub import_names: Vec, + /// Import namespaces provided by the project. + /// + /// Introduced by PEP 794, requires metadata version 2.5. + pub import_namespaces: Vec, /// A string containing the name of another core metadata field. pub dynamic: Vec, } @@ -151,6 +160,11 @@ impl Metadata23 { let requires_external = headers.get_all_values("Requires-External").collect(); let project_urls = ProjectUrls::from_iter_str(headers.get_all_values("Project-URL")); let provides_extra = headers.get_all_values("Provides-Extra").collect(); + let import_names: Vec = headers.get_all_values("Import-Name").collect(); + let import_namespaces: Vec = headers.get_all_values("Import-Namespace").collect(); + // PEP 794 requires rejecting modules that are used both in import names and import + // namespaces. (Nesting is allowed, only exact matches are forbidden.) + validate_import_name_overlap(&import_names, &import_namespaces)?; let description_content_type = headers.get_first_value("Description-Content-Type"); let dynamic = headers.get_all_values("Dynamic").collect(); Ok(Self { @@ -180,6 +194,8 @@ impl Metadata23 { requires_external, project_urls, provides_extra, + import_names, + import_namespaces, dynamic, }) } @@ -274,6 +290,8 @@ impl Metadata23 { write_all(&mut writer, "Requires-External", &self.requires_external); write_all(&mut writer, "Project-URL", self.project_urls.to_vec_str()); write_all(&mut writer, "Provides-Extra", &self.provides_extra); + write_all(&mut writer, "Import-Name", &self.import_names); + write_all(&mut writer, "Import-Namespace", &self.import_namespaces); write_opt_str( &mut writer, "Description-Content-Type", @@ -289,6 +307,31 @@ impl Metadata23 { } } +fn import_name_base(value: &str) -> &str { + match value.split_once(';') { + Some((base, _suffix)) => base.trim_end(), + None => value, + } +} + +fn validate_import_name_overlap( + import_names: &[String], + import_namespaces: &[String], +) -> Result<(), MetadataError> { + let import_name_set: BTreeSet<_> = import_names + .iter() + .map(|import_name| import_name_base(import_name)) + .collect(); + if let Some(overlap) = import_namespaces + .iter() + .map(|import_namespace| import_name_base(import_namespace)) + .find(|import_namespace| import_name_set.contains(import_namespace)) + { + return Err(MetadataError::DuplicateImportName(overlap.to_string())); + } + Ok(()) +} + impl FromStr for Metadata23 { type Err = MetadataError; @@ -363,6 +406,7 @@ impl ProjectUrls { mod tests { use super::*; use crate::MetadataError; + use insta::assert_snapshot; #[test] fn test_parse_from_str() { @@ -393,4 +437,35 @@ mod tests { assert_eq!(meta.author.as_deref(), Some("中文")); assert_eq!(meta.description.as_deref(), Some("一个 Python 包")); } + + #[test] + fn import_name_round_trip() { + let metadata = Metadata23 { + metadata_version: "2.5".to_string(), + name: "pkg".to_string(), + version: "1.0".to_string(), + import_names: vec!["spam.foo".to_string(), "spam.eggs; private".to_string()], + import_namespaces: vec!["spam".to_string(), "zope".to_string()], + ..Default::default() + }; + + let formatted = metadata.core_metadata_format(); + assert_eq!( + formatted, + "Metadata-Version: 2.5\nName: pkg\nVersion: 1.0\nImport-Name: spam.foo\nImport-Name: spam.eggs; private\nImport-Namespace: spam\nImport-Namespace: zope\n" + ); + + let parsed: Metadata23 = formatted.parse().unwrap(); + assert_eq!(parsed.import_names, metadata.import_names); + assert_eq!(parsed.import_namespaces, metadata.import_namespaces); + } + + #[test] + fn import_name_overlap() { + let metadata = "Metadata-Version: 2.5\nName: pkg\nVersion: 1.0\nImport-Name: spam ; private\nImport-Namespace: spam\n"; + assert_snapshot!( + Metadata23::parse(metadata.as_bytes()).unwrap_err(), + @"`Import-Name` and `Import-Namespace` must not both contain `spam`" + ); + } } diff --git a/crates/uv-pypi-types/src/metadata/mod.rs b/crates/uv-pypi-types/src/metadata/mod.rs index d068da81846f1..01820a3fdafa2 100644 --- a/crates/uv-pypi-types/src/metadata/mod.rs +++ b/crates/uv-pypi-types/src/metadata/mod.rs @@ -50,6 +50,8 @@ pub enum MetadataError { InvalidName(#[from] InvalidNameError), #[error("Invalid `Metadata-Version` field: {0}")] InvalidMetadataVersion(String), + #[error("`Import-Name` and `Import-Namespace` must not both contain `{0}`")] + DuplicateImportName(String), #[error("Reading metadata from `PKG-INFO` requires Metadata 2.2 or later (found: {0})")] UnsupportedMetadataVersion(String), #[error("The following field was marked as dynamic: {0}")] diff --git a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs index ffe6245a579d7..fc438a3bf109d 100644 --- a/crates/uv-pypi-types/src/metadata/pyproject_toml.rs +++ b/crates/uv-pypi-types/src/metadata/pyproject_toml.rs @@ -7,9 +7,9 @@ use serde::de::IntoDeserializer; use tracing::instrument; use uv_normalize::{ExtraName, PackageName}; -use uv_pep440::Version; +use uv_pep440::{Version, VersionSpecifiers}; -use crate::MetadataError; +use crate::{LenientVersionSpecifiers, MetadataError}; /// A `pyproject.toml` as specified in PEP 517. #[derive(Deserialize, Debug, Clone)] @@ -28,6 +28,33 @@ impl PyProjectToml { .map_err(MetadataError::InvalidPyprojectTomlSchema)?; Ok(pyproject_toml) } + + /// Extract static `requires-python` metadata from a PEP 621 project. + /// + /// Unlike [`crate::ResolutionMetadata::parse_pyproject_toml`], this does not require static + /// project version or dependency metadata. + pub fn requires_python(&self) -> Result, MetadataError> { + let project = self + .project + .as_ref() + .ok_or(MetadataError::FieldNotFound("project"))?; + + if project + .dynamic + .as_ref() + .is_some_and(|dynamic| dynamic.iter().any(|field| field == "requires-python")) + { + return Err(MetadataError::DynamicField("requires-python")); + } + + Ok(project + .requires_python + .as_ref() + .map(|requires_python| { + LenientVersionSpecifiers::from_str(requires_python).map(VersionSpecifiers::from) + }) + .transpose()?) + } } /// PEP 621 project metadata. @@ -92,3 +119,46 @@ pub struct Tool { pub struct ToolPoetry { pub name: Option, } + +#[cfg(test)] +mod tests { + use super::PyProjectToml; + use crate::MetadataError; + + #[test] + fn requires_python_allows_unrelated_dynamic_metadata() { + let pyproject_toml = PyProjectToml::from_toml( + r#" + [project] + name = "example" + requires-python = ">=3.11,<3.13" + dynamic = ["version", "dependencies"] + "#, + "pyproject.toml", + ) + .unwrap(); + + assert_eq!( + pyproject_toml.requires_python().unwrap(), + Some(">=3.11,<3.13".parse().unwrap()) + ); + } + + #[test] + fn requires_python_rejects_dynamic_field() { + let pyproject_toml = PyProjectToml::from_toml( + r#" + [project] + name = "example" + dynamic = ["requires-python"] + "#, + "pyproject.toml", + ) + .unwrap(); + + assert!(matches!( + pyproject_toml.requires_python(), + Err(MetadataError::DynamicField("requires-python")) + )); + } +} diff --git a/crates/uv-pypi-types/src/module_name.rs b/crates/uv-pypi-types/src/module_name.rs new file mode 100644 index 0000000000000..b1fdadd2e37f7 --- /dev/null +++ b/crates/uv-pypi-types/src/module_name.rs @@ -0,0 +1,172 @@ +use std::borrow::Cow; +use std::fmt::Display; +use std::str::FromStr; + +use serde::{Serialize, Serializer}; +use thiserror::Error; + +use crate::{Identifier, IdentifierParseError}; + +/// The name of an importable Python module. +/// +/// This is a dotted sequence of Python identifiers, like `foo` or `foo.bar`. +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct ModuleName(Box); + +#[derive(Debug, Clone, Error)] +pub enum ModuleNameParseError { + #[error("A module name must not be empty")] + Empty, + #[error("Invalid module name component `{component}` in `{module}`")] + InvalidComponent { + component: Box, + module: Box, + #[source] + err: IdentifierParseError, + }, +} + +impl ModuleName { + pub fn new(module: impl Into>) -> Result { + let module = module.into(); + if module.is_empty() { + return Err(ModuleNameParseError::Empty); + } + + for component in module.split('.') { + Self::validate_component(&module, component)?; + } + + Ok(Self(module)) + } + + pub fn from_components<'a>( + components: impl IntoIterator, + ) -> Result { + let components = components.into_iter().collect::>(); + if components.is_empty() { + return Err(ModuleNameParseError::Empty); + } + + let module = components.join(".").into_boxed_str(); + for component in components { + Self::validate_component(&module, component)?; + } + + Ok(Self(module)) + } + + /// Iterate over this module and its parent modules. + /// + /// For example, `foo.bar.baz` yields `foo`, `foo.bar`, and `foo.bar.baz`. + pub fn prefixes(&self) -> impl Iterator + '_ { + self.0 + .match_indices('.') + .map(|(index, _)| Self(Box::from(&self.0[..index]))) + .chain(std::iter::once(self.clone())) + } + + fn validate_component(module: &str, component: &str) -> Result<(), ModuleNameParseError> { + Identifier::new(component.to_string()).map_err(|err| { + ModuleNameParseError::InvalidComponent { + component: component.to_string().into_boxed_str(), + module: module.into(), + err, + } + })?; + Ok(()) + } +} + +impl FromStr for ModuleName { + type Err = ModuleNameParseError; + + fn from_str(module: &str) -> Result { + Self::new(module) + } +} + +impl Display for ModuleName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for ModuleName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl<'de> serde::de::Deserialize<'de> for ModuleName { + fn deserialize(deserializer: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + let s = >::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl Serialize for ModuleName { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + Serialize::serialize(&self.0, serializer) + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for ModuleName { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("ModuleName") + } + + fn json_schema(_generator: &mut schemars::generate::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "pattern": r"^[_\p{Alphabetic}][_0-9\p{Alphabetic}]*(\.[_\p{Alphabetic}][_0-9\p{Alphabetic}]*)*$", + "description": "A dotted Python module name" + }) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use insta::assert_snapshot; + + use super::ModuleName; + + #[test] + fn valid() { + for module_name in ["abc", "abc.def", "_abc", "férrîs", "package.안녕하세요"] { + assert!(ModuleName::from_str(module_name).is_ok(), "{module_name}"); + } + } + + #[test] + fn invalid() { + assert_snapshot!( + ModuleName::from_str("foo-bar").unwrap_err(), + @"Invalid module name component `foo-bar` in `foo-bar`" + ); + assert_snapshot!( + ModuleName::from_str("foo.").unwrap_err(), + @"Invalid module name component `` in `foo.`" + ); + } + + #[test] + fn prefixes() { + let prefixes = ModuleName::from_str("foo.bar.baz") + .expect("valid module name") + .prefixes() + .map(|module| module.to_string()) + .collect::>(); + + assert_eq!(prefixes, ["foo", "foo.bar", "foo.bar.baz"]); + } +} diff --git a/crates/uv-pypi-types/src/parsed_url.rs b/crates/uv-pypi-types/src/parsed_url.rs index 7872811efac3f..48de02146c0c1 100644 --- a/crates/uv-pypi-types/src/parsed_url.rs +++ b/crates/uv-pypi-types/src/parsed_url.rs @@ -554,7 +554,7 @@ impl From<&ParsedArchiveUrl> for DirectUrl { impl From<&ParsedGitDirectoryUrl> for DirectUrl { fn from(value: &ParsedGitDirectoryUrl) -> Self { Self::VcsUrl { - url: value.url.repository().to_string(), + url: value.url.url().to_string(), vcs_info: VcsInfo { vcs: VcsKind::Git, commit_id: value.url.precise().as_ref().map(ToString::to_string), @@ -570,7 +570,7 @@ impl From<&ParsedGitDirectoryUrl> for DirectUrl { impl From<&ParsedGitPathUrl> for DirectUrl { fn from(value: &ParsedGitPathUrl) -> Self { Self::VcsUrl { - url: value.url.repository().to_string(), + url: value.url.url().to_string(), vcs_info: VcsInfo { vcs: VcsKind::Git, commit_id: value.url.precise().as_ref().map(ToString::to_string), @@ -655,7 +655,7 @@ impl From for DisplaySafeUrl { mod tests { use anyhow::Result; - use crate::parsed_url::ParsedUrl; + use crate::{DirectUrl, parsed_url::ParsedUrl}; use uv_redacted::DisplaySafeUrl; #[test] @@ -696,6 +696,21 @@ mod tests { Ok(()) } + #[test] + fn direct_url_preserves_git_repository_url() -> Result<()> { + for expected in [ + "git+ssh://git@github.com/pallets/flask.git", + "git+ssh://git@github.com/pallets/flask.git#path=dist/flask-1.0-py3-none-any.whl", + ] { + let expected = DisplaySafeUrl::parse(expected)?; + let parsed_url = ParsedUrl::try_from(expected.clone())?; + let actual = DisplaySafeUrl::try_from(&DirectUrl::from(&parsed_url))?; + assert_eq!(expected, actual); + } + + Ok(()) + } + #[test] #[cfg(unix)] fn direct_url_from_url_absolute() -> Result<()> { diff --git a/crates/uv-python/Cargo.toml b/crates/uv-python/Cargo.toml index 8c4b1fa5a9dbc..3b3595229533c 100644 --- a/crates/uv-python/Cargo.toml +++ b/crates/uv-python/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-python" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-python/README.md b/crates/uv-python/README.md index 3ccedc3c2d612..ea60e2e89e283 100644 --- a/crates/uv-python/README.md +++ b/crates/uv-python/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-python). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-python). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-python/python/get_interpreter_info.py b/crates/uv-python/python/get_interpreter_info.py index 30735ae3e5dd4..fdd9f8d9ec3e0 100644 --- a/crates/uv-python/python/get_interpreter_info.py +++ b/crates/uv-python/python/get_interpreter_info.py @@ -4,13 +4,13 @@ The script will exit with status 0 on known error that are turned into rust errors. """ -import site -import sys - +import importlib.machinery import json import os import platform +import site import struct +import sys import sysconfig @@ -682,6 +682,7 @@ def main() -> None: "sys_path": sys.path[1:], "site_packages": site.getsitepackages(), "stdlib": sysconfig.get_path("stdlib"), + "extension_suffixes": importlib.machinery.EXTENSION_SUFFIXES, # Prior to the introduction of `sysconfig` patching, python-build-standalone installations would always use # "/install" as the prefix. With `sysconfig` patching, we rewrite the prefix to match the actual installation # location. So in newer versions, we also write a dedicated flag to indicate standalone builds. diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 0384a93b96a1b..7f2f736bfa163 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -1,5 +1,4 @@ use itertools::{Either, Itertools}; -use owo_colors::AnsiColors; use regex::Regex; use rustc_hash::{FxBuildHasher, FxHashSet}; use same_file::is_same_file; @@ -21,8 +20,7 @@ use uv_pep440::{ }; use uv_preview::Preview; use uv_static::EnvVars; -use uv_warnings::anstream; -use uv_warnings::warn_user_once; +use uv_warnings::{warn_user_once, write_warning_chain}; use which::{which, which_all}; use crate::downloads::{ManagedPythonDownloadList, PlatformRequest, PythonDownloadRequest}; @@ -1514,19 +1512,10 @@ pub(crate) async fn find_best_python_installation( return Err(error); } - let mut error_chain = String::new(); - // Writing to a string can't fail with errors (panics on allocation failure) let error = anyhow::Error::from(error).context(format!( "A managed Python download is available for {request}, but an error occurred when attempting to download it." )); - uv_warnings::write_error_chain( - error.as_ref(), - &mut error_chain, - "warning", - AnsiColors::Yellow, - ) - .unwrap(); - anstream::eprint!("{}", error_chain); + write_warning_chain(error.as_ref()).expect("writing to stderr should not fail"); previous_fetch_failed = true; } } diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 41e3392ad335d..6f13c1e81a2d1 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -54,6 +54,7 @@ pub struct Interpreter { sys_executable: PathBuf, site_packages: Vec, stdlib: PathBuf, + extension_suffixes: Vec>, standalone: bool, tags: OnceLock, target: Option, @@ -90,6 +91,7 @@ impl Interpreter { sys_executable: info.sys_executable, site_packages: info.site_packages, stdlib: info.stdlib, + extension_suffixes: info.extension_suffixes, standalone: info.standalone, tags: OnceLock::new(), target: None, @@ -458,6 +460,11 @@ impl Interpreter { &self.sys_executable } + /// Return the recognized native extension module suffixes for this Python interpreter. + pub fn extension_suffixes(&self) -> &[Box] { + &self.extension_suffixes + } + /// Return the "real" queried executable path for this Python interpreter. pub fn real_executable(&self) -> &Path { &self.real_executable @@ -954,6 +961,7 @@ struct InterpreterInfo { sys_path: Vec, site_packages: Vec, stdlib: PathBuf, + extension_suffixes: Vec>, standalone: bool, pointer_size: PointerSize, gil_disabled: bool, @@ -1384,6 +1392,7 @@ mod tests { "/home/ferris/.pyenv/versions/3.12.0/lib/python3.12/site-packages" ], "stdlib": "/home/ferris/.pyenv/versions/3.12.0/lib/python3.12", + "extension_suffixes": [".cpython-312-x86_64-linux-gnu.so", ".abi3.so", ".so"], "scheme": { "data": "/home/ferris/.pyenv/versions/3.12.0", "include": "/home/ferris/.pyenv/versions/3.12.0/include", diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index e97673e435d83..811e98d0e1e03 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -360,6 +360,7 @@ mod tests { "/home/ferris/.pyenv/versions/{FULL_VERSION}/lib/python{VERSION}/site-packages" ], "stdlib": "/home/ferris/.pyenv/versions/{FULL_VERSION}/lib/python{VERSION}", + "extension_suffixes": [".cpython-{VERSION}-x86_64-linux-gnu.so", ".abi3.so", ".so"], "scheme": { "data": "/home/ferris/.pyenv/versions/{FULL_VERSION}", "include": "/home/ferris/.pyenv/versions/{FULL_VERSION}/include", @@ -455,6 +456,7 @@ mod tests { "/lib/python{VERSION}/site-packages" ], "stdlib": "//lib/python{VERSION}", + "extension_suffixes": [".cpython-{VERSION}-wasm32-emscripten.so", ".so"], "scheme": { "platlib": "//lib/python{VERSION}/site-packages", "purelib": "//lib/python{VERSION}/site-packages", diff --git a/crates/uv-redacted/Cargo.toml b/crates/uv-redacted/Cargo.toml index 587f3253ff17c..98adec6d189b9 100644 --- a/crates/uv-redacted/Cargo.toml +++ b/crates/uv-redacted/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-redacted" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-redacted/README.md b/crates/uv-redacted/README.md index a9f63fbcafdd8..7fe8536dc5c12 100644 --- a/crates/uv-redacted/README.md +++ b/crates/uv-redacted/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-redacted). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-redacted). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-requirements-txt/Cargo.toml b/crates/uv-requirements-txt/Cargo.toml index a58c24009b0d6..7c10ebd0dd7fa 100644 --- a/crates/uv-requirements-txt/Cargo.toml +++ b/crates/uv-requirements-txt/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-requirements-txt" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-requirements-txt/README.md b/crates/uv-requirements-txt/README.md index 29a07f5774e60..1d0331c1c9fe6 100644 --- a/crates/uv-requirements-txt/README.md +++ b/crates/uv-requirements-txt/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-requirements-txt). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-requirements-txt). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-requirements/Cargo.toml b/crates/uv-requirements/Cargo.toml index ffc2055b30e7f..f4c08c3b834aa 100644 --- a/crates/uv-requirements/Cargo.toml +++ b/crates/uv-requirements/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-requirements" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-requirements/README.md b/crates/uv-requirements/README.md index 93d6a4cff794c..c19a50cc46e07 100644 --- a/crates/uv-requirements/README.md +++ b/crates/uv-requirements/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-requirements). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-requirements). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-resolver/Cargo.toml b/crates/uv-resolver/Cargo.toml index fd3744b640595..789ea0ff4d994 100644 --- a/crates/uv-resolver/Cargo.toml +++ b/crates/uv-resolver/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-resolver" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-resolver/README.md b/crates/uv-resolver/README.md index fdfc43fbe60c8..f8837c10292da 100644 --- a/crates/uv-resolver/README.md +++ b/crates/uv-resolver/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-resolver). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-resolver). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-resolver/src/lock/export/metadata.rs b/crates/uv-resolver/src/lock/export/metadata.rs index f618d9a6bc734..eaa69f6658bc0 100644 --- a/crates/uv-resolver/src/lock/export/metadata.rs +++ b/crates/uv-resolver/src/lock/export/metadata.rs @@ -2,23 +2,25 @@ use std::collections::BTreeMap; use std::fmt::Display; use uv_distribution_filename::WheelFilename; -use uv_distribution_types::{RequiresPython, UrlString}; +use uv_distribution_types::{Name, RequiresPython, ResolvedDist, UrlString}; use uv_fs::PortablePathBuf; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::Version; -use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts}; +use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts, ModuleName}; use uv_workspace::Workspace; -use crate::Lock; use crate::lock::{ Dependency, DirectSource, PackageId, RegistrySource, Source, SourceDist, SourceDistMetadata, Wheel, WheelWireSource, ZstdWheel, }; +use crate::{Lock, LockError}; #[derive(Debug, thiserror::Error)] enum MetadataErrorKind { #[error(transparent)] Serialize(#[from] serde_json::error::Error), + #[error(transparent)] + Lock(#[from] LockError), } #[derive(Debug)] @@ -66,6 +68,9 @@ pub struct Metadata { requires_python: RequiresPython, /// Info about conflicting packages conflicts: MetadataConflicts, + /// A mapping from importable module names to the package nodes that provide them + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + module_owners: BTreeMap>, /// An index of which nodes are workspace members /// /// These entries are often what you should use as the entry-points into the `resolve` graph. @@ -103,6 +108,13 @@ struct MetadataWorkspaceMember { id: MetadataNodeIdFlat, } +/// An installed distribution that provides an importable module. +#[derive(Debug, serde::Serialize)] +struct MetadataModuleOwner { + /// Key for the package node in the `resolution` graph. + package_id: MetadataNodeIdFlat, +} + /// A node in the dependency graph /// /// There are 4 kinds of nodes: @@ -818,6 +830,7 @@ impl Metadata { version: SchemaVersion::Preview, }, conflicts, + module_owners: BTreeMap::new(), workspace_root, requires_python: lock.requires_python.clone(), members, @@ -825,6 +838,36 @@ impl Metadata { }) } + pub fn package_node_id( + workspace_root: &PortablePathBuf, + dist: &ResolvedDist, + ) -> Result { + let source = Source::from_resolved_dist(dist, workspace_root.as_ref())?; + Ok(MetadataNodeId { + name: dist.name().clone(), + version: dist.version().cloned(), + source: MetadataSource::from_source(workspace_root, source), + kind: MetadataNodeKind::Package, + } + .to_flat()) + } + + #[must_use] + pub fn with_module_owners(mut self, module_owners: BTreeMap>) -> Self { + self.module_owners = module_owners + .into_iter() + .filter_map(|(module, owners)| { + let owners = owners + .into_iter() + .filter(|package_id| self.resolution.contains_key(package_id)) + .map(|package_id| MetadataModuleOwner { package_id }) + .collect::>(); + (!owners.is_empty()).then_some((module, owners)) + }) + .collect(); + self + } + pub fn to_json(&self) -> Result { Ok(serde_json::to_string_pretty(self)?) } diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index 926b935f5e32d..a2b76344c7074 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -676,7 +676,7 @@ impl<'lock> PylockToml { extras: &ExtrasSpecificationWithDefaults, dev: &DependencyGroupsWithDefaults, annotate: bool, - editable: Option, + editable: Option<&EditableMode>, install_options: &'lock InstallOptions, ) -> Result { // Extract the packages from the lock file. @@ -794,10 +794,12 @@ impl<'lock> PylockToml { .unwrap_or_else(|| sdist.install_path.to_path_buf()) .into_boxed_path(), ), - editable: match editable { + editable: match editable + .and_then(|editable| editable.for_package(&package.id.name)) + { None => sdist.editable, - Some(EditableMode::NonEditable) => None, - Some(EditableMode::Editable) => Some(true), + Some(false) => None, + Some(true) => Some(true), }, subdirectory: None, }), diff --git a/crates/uv-resolver/src/lock/export/requirements_txt.rs b/crates/uv-resolver/src/lock/export/requirements_txt.rs index 00e93c76885b2..525c69a435ff3 100644 --- a/crates/uv-resolver/src/lock/export/requirements_txt.rs +++ b/crates/uv-resolver/src/lock/export/requirements_txt.rs @@ -139,11 +139,15 @@ impl std::fmt::Display for RequirementsTxtExport<'_> { write!(f, "{}", anchor(path).portable_display())?; } } - Source::Editable(path) => match self.editable { - None | Some(EditableMode::Editable) => { + Source::Editable(path) => match self + .editable + .as_ref() + .and_then(|editable| editable.for_package(&package.id.name)) + { + None | Some(true) => { write!(f, "-e {}", anchor(path).portable_display())?; } - Some(EditableMode::NonEditable) => { + Some(false) => { if path.is_absolute() { write!( f, diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 796af43b72a38..c1c5d17f55f18 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1975,7 +1975,17 @@ impl Lock { continue; } - if let Some(version) = package.id.version.as_ref() { + // Validating a direct URL package requires retrieving metadata from the remote + // artifact. In offline mode, preserve the metadata captured in the lockfile rather + // than requiring that artifact to already be present in the cache. + if matches!(&package.id.source, Source::Direct(..)) + && database.client().unmanaged.connectivity().is_offline() + { + trace!( + "Skipping metadata validation for `{}` because its direct URL cannot be refreshed while offline", + package.id + ); + } else if let Some(version) = package.id.version.as_ref() { // If the distribution is a source tree, attempt to validate it from statically // available `pyproject.toml` metadata before converting it to an installable // distribution. This avoids requiring build permission for static local packages. diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs index 8d48e10edccc5..cb5abc4d4413a 100644 --- a/crates/uv-resolver/src/pubgrub/report.rs +++ b/crates/uv-resolver/src/pubgrub/report.rs @@ -1122,6 +1122,9 @@ impl PubGrubReportFormatter<'_> { if index_capabilities.forbidden(&index.url) { hints.insert(PubGrubHint::ForbiddenIndex { index: index.url.clone(), + any_successful_response: available_indexes + .values() + .any(|indexes| indexes.contains(&index.url)), }); } } @@ -1360,7 +1363,11 @@ pub enum PubGrubHint { /// An index returned an Unauthorized (401) response. UnauthorizedIndex { index: IndexUrl }, /// An index returned a Forbidden (403) response. - ForbiddenIndex { index: IndexUrl }, + ForbiddenIndex { + index: IndexUrl, + // excluded from `PartialEq` and `Hash` + any_successful_response: bool, + }, /// None of the available wheels for a package have a compatible Python language tag (e.g., /// `cp310` in `cp310-abi3-manylinux_2_17_x86_64.whl`). LanguageTags { @@ -1550,7 +1557,7 @@ impl From for PubGrubHintCore { } PubGrubHint::UncheckedIndex { name: package, .. } => Self::UncheckedIndex { package }, PubGrubHint::UnauthorizedIndex { index } => Self::UnauthorizedIndex { index }, - PubGrubHint::ForbiddenIndex { index } => Self::ForbiddenIndex { index }, + PubGrubHint::ForbiddenIndex { index, .. } => Self::ForbiddenIndex { index }, PubGrubHint::NoBuild { package, .. } => Self::NoBuild { package }, PubGrubHint::NoBinary { package, .. } => Self::NoBinary { package }, PubGrubHint::LanguageTags { package, .. } => Self::LanguageTags { package }, @@ -1813,13 +1820,25 @@ impl std::fmt::Display for PubGrubHint { "401 Unauthorized".red(), ) } - Self::ForbiddenIndex { index } => { - write!( - f, - "An index URL ({}) returned a {} error. This could indicate lack of valid authentication credentials, or the package may not exist on this index.", - index.without_credentials().cyan(), - "403 Forbidden".red(), - ) + Self::ForbiddenIndex { + index, + any_successful_response, + } => { + if *any_successful_response { + write!( + f, + "An index ({}) returned a {} error, but uv received a successful response from another request to the index. If the failing package is not present on this index, consider adding `ignore-error-codes = [403]` to the index's `[[tool.uv.index]]` entry to continue searching across indexes.", + index.without_credentials().cyan(), + "403 Forbidden".red(), + ) + } else { + write!( + f, + "An index ({}) returned a {} error. Check that the index URL is correct and the credentials are valid.", + index.without_credentials().cyan(), + "403 Forbidden".red(), + ) + } } Self::NoBuild { package, option } => { let option = match option { diff --git a/crates/uv-scripts/Cargo.toml b/crates/uv-scripts/Cargo.toml index fb3ada7df900b..2d287357bc359 100644 --- a/crates/uv-scripts/Cargo.toml +++ b/crates/uv-scripts/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-scripts" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-scripts/README.md b/crates/uv-scripts/README.md index 4d3f2bed029d8..a242eb964d202 100644 --- a/crates/uv-scripts/README.md +++ b/crates/uv-scripts/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-scripts). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-scripts). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 32a2f14a860d1..fa502effa93a4 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -435,6 +435,8 @@ pub enum Pep723Error { "An opening tag (`# /// script`) was found without a closing tag (`# ///`). Ensure that every line between the opening and closing tags (including empty lines) starts with a leading `#`." )] UnclosedBlock, + #[error("The script contains multiple PEP 723 metadata blocks")] + DuplicateBlock, #[error("The PEP 723 metadata block is missing from the script.")] MissingTag, #[error(transparent)] @@ -570,15 +572,65 @@ impl ScriptTag { // We need to discard the last two lines. toml.truncate(index - 1); + // Extract the remaining content. + let postlude = contents.lines().skip(index + 1).collect::>(); + + // Ensure that the remaining content doesn't include another complete `script` block. + // A `# /// script` line can be embedded content inside another typed block. + let mut lines = postlude.iter().peekable(); + while let Some(line) = lines.next() { + // Capture the metadata. + let Some(metadata_type) = line.strip_prefix("# /// ") else { + continue; + }; + + // Parse the metadata type per spec + if metadata_type.is_empty() + || !metadata_type + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + continue; + } + + let is_script_block = metadata_type == "script"; + let mut is_closed = false; + while let Some(line) = lines.next() { + // Per e.g. # dependencies = [] + let Some(content) = line.strip_prefix('#') else { + break; + }; + if !(content.is_empty() || content.starts_with(' ')) { + break; + } + + if *line == "# ///" { + let Some(next_line) = lines.peek() else { + is_closed = true; + break; + }; + + let Some(next_content) = next_line.strip_prefix('#') else { + is_closed = true; + break; + }; + + if !(next_content.is_empty() || next_content.starts_with(' ')) { + is_closed = true; + break; + } + } + } + + if is_script_block && is_closed { + return Err(Pep723Error::DuplicateBlock); + } + } + // Join the lines into a single string. let prelude = prelude.to_string(); let metadata = toml.join("\n") + "\n"; - let postlude = contents - .lines() - .skip(index + 1) - .collect::>() - .join("\n") - + "\n"; + let postlude = postlude.join("\n") + "\n"; Ok(Some(Self { prelude, @@ -846,6 +898,39 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn unclosed_second_script_block_is_not_duplicate() { + let contents = indoc::indoc! {r#" + # /// script + # dependencies = ["requests"] + # /// + + print("Hello, world!") + + # /// script + "#}; + + assert!(ScriptTag::parse(contents.as_bytes()).is_ok()); + } + + #[test] + fn other_script_block_is_ignored() { + let contents = indoc::indoc! {r#" + # /// script + # dependencies = ["requests"] + # /// + + + # /// other + # /// script + # /// + + print("Hello, world!") + "#}; + + assert!(ScriptTag::parse(contents.as_bytes()).is_ok()); + } + #[test] fn serialize_metadata_formatting() { let metadata = indoc::indoc! {r" diff --git a/crates/uv-settings/Cargo.toml b/crates/uv-settings/Cargo.toml index 7e5b2d08cb193..248d1fca96857 100644 --- a/crates/uv-settings/Cargo.toml +++ b/crates/uv-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-settings" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-settings/README.md b/crates/uv-settings/README.md index dcfc025c08d72..a3bc18e318566 100644 --- a/crates/uv-settings/README.md +++ b/crates/uv-settings/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-settings). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-settings). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-shell/Cargo.toml b/crates/uv-shell/Cargo.toml index 071e4b2352689..749bcb506f1c5 100644 --- a/crates/uv-shell/Cargo.toml +++ b/crates/uv-shell/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-shell" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-shell/README.md b/crates/uv-shell/README.md index 59f43f6527cc0..994451a3b3328 100644 --- a/crates/uv-shell/README.md +++ b/crates/uv-shell/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-shell). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-shell). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-small-str/Cargo.toml b/crates/uv-small-str/Cargo.toml index 6e46be93df276..7212b11ff98e6 100644 --- a/crates/uv-small-str/Cargo.toml +++ b/crates/uv-small-str/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-small-str" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-small-str/README.md b/crates/uv-small-str/README.md index 0b9f06d6f2e64..a9b87f6a642dc 100644 --- a/crates/uv-small-str/README.md +++ b/crates/uv-small-str/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-small-str). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-small-str). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-state/Cargo.toml b/crates/uv-state/Cargo.toml index f08c7fe0bedf3..0e3350ac9ae94 100644 --- a/crates/uv-state/Cargo.toml +++ b/crates/uv-state/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-state" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-state/README.md b/crates/uv-state/README.md index e938ccf84a371..aea6818b64ae6 100644 --- a/crates/uv-state/README.md +++ b/crates/uv-state/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-state). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-state). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-static/Cargo.toml b/crates/uv-static/Cargo.toml index d8fe9d70b3598..9b5eb035ec863 100644 --- a/crates/uv-static/Cargo.toml +++ b/crates/uv-static/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-static" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-static/README.md b/crates/uv-static/README.md index 45c7f07500dd2..384e0a8a9d1d2 100644 --- a/crates/uv-static/README.md +++ b/crates/uv-static/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-static). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-static). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-static/src/known_stdlib.rs b/crates/uv-static/src/known_stdlib.rs new file mode 100644 index 0000000000000..ca582a699e578 --- /dev/null +++ b/crates/uv-static/src/known_stdlib.rs @@ -0,0 +1,433 @@ +//! Generated with `uv run scripts/generate-known-stdlib.py`. + +/// Return whether a normalized package name matches a Python standard-library module. +pub fn is_known_standard_library_package(minor_version: u8, package: &str) -> bool { + matches!( + (minor_version, package), + ( + _, + "abc" + | "antigravity" + | "argparse" + | "array" + | "ast" + | "asyncio" + | "atexit" + | "base64" + | "bdb" + | "binascii" + | "bisect" + | "builtins" + | "bz2" + | "calendar" + | "cmath" + | "cmd" + | "code" + | "codecs" + | "codeop" + | "collections" + | "colorsys" + | "compileall" + | "concurrent" + | "configparser" + | "contextlib" + | "contextvars" + | "copy" + | "copyreg" + | "cprofile" + | "csv" + | "ctypes" + | "curses" + | "dataclasses" + | "datetime" + | "dbm" + | "decimal" + | "difflib" + | "dis" + | "doctest" + | "email" + | "encodings" + | "ensurepip" + | "enum" + | "errno" + | "faulthandler" + | "fcntl" + | "filecmp" + | "fileinput" + | "fnmatch" + | "fractions" + | "ftplib" + | "functools" + | "gc" + | "genericpath" + | "getopt" + | "getpass" + | "gettext" + | "glob" + | "grp" + | "gzip" + | "hashlib" + | "heapq" + | "hmac" + | "html" + | "http" + | "idlelib" + | "imaplib" + | "importlib" + | "inspect" + | "io" + | "ipaddress" + | "itertools" + | "json" + | "keyword" + | "linecache" + | "locale" + | "logging" + | "lzma" + | "mailbox" + | "marshal" + | "math" + | "mimetypes" + | "mmap" + | "modulefinder" + | "msvcrt" + | "multiprocessing" + | "netrc" + | "nt" + | "ntpath" + | "nturl2path" + | "numbers" + | "opcode" + | "operator" + | "optparse" + | "os" + | "pathlib" + | "pdb" + | "pickle" + | "pickletools" + | "pkgutil" + | "platform" + | "plistlib" + | "poplib" + | "posix" + | "posixpath" + | "pprint" + | "profile" + | "pstats" + | "pty" + | "pwd" + | "py-compile" + | "pyclbr" + | "pydoc" + | "pydoc-data" + | "pyexpat" + | "queue" + | "quopri" + | "random" + | "re" + | "readline" + | "reprlib" + | "resource" + | "rlcompleter" + | "runpy" + | "sched" + | "secrets" + | "select" + | "selectors" + | "shelve" + | "shlex" + | "shutil" + | "signal" + | "site" + | "smtplib" + | "socket" + | "socketserver" + | "sqlite3" + | "ssl" + | "stat" + | "statistics" + | "string" + | "stringprep" + | "struct" + | "subprocess" + | "symtable" + | "sys" + | "sysconfig" + | "syslog" + | "tabnanny" + | "tarfile" + | "tempfile" + | "termios" + | "textwrap" + | "this" + | "threading" + | "time" + | "timeit" + | "tkinter" + | "token" + | "tokenize" + | "trace" + | "traceback" + | "tracemalloc" + | "tty" + | "turtle" + | "turtledemo" + | "types" + | "typing" + | "unicodedata" + | "unittest" + | "urllib" + | "uuid" + | "venv" + | "warnings" + | "wave" + | "weakref" + | "webbrowser" + | "winreg" + | "winsound" + | "wsgiref" + | "xml" + | "xmlrpc" + | "xx" + | "xxlimited" + | "xxsubtype" + | "zipapp" + | "zipfile" + | "zipimport" + | "zlib" + ) | ( + 7, + "aifc" + | "asynchat" + | "asyncore" + | "audioop" + | "binhex" + | "cgi" + | "cgitb" + | "chunk" + | "crypt" + | "distutils" + | "dummy-threading" + | "formatter" + | "imghdr" + | "imp" + | "lib2to3" + | "macpath" + | "mailcap" + | "msilib" + | "nis" + | "nntplib" + | "ossaudiodev" + | "parser" + | "pipes" + | "smtpd" + | "sndhdr" + | "spwd" + | "sre-compile" + | "sre-constants" + | "sre-parse" + | "sunau" + | "symbol" + | "telnetlib" + | "uu" + | "xdrlib" + ) | ( + 8, + "aifc" + | "asynchat" + | "asyncore" + | "audioop" + | "binhex" + | "cgi" + | "cgitb" + | "chunk" + | "crypt" + | "distutils" + | "dummy-threading" + | "formatter" + | "imghdr" + | "imp" + | "lib2to3" + | "mailcap" + | "msilib" + | "nis" + | "nntplib" + | "ossaudiodev" + | "parser" + | "pipes" + | "smtpd" + | "sndhdr" + | "spwd" + | "sre-compile" + | "sre-constants" + | "sre-parse" + | "sunau" + | "symbol" + | "telnetlib" + | "uu" + | "xdrlib" + ) | ( + 9, + "aifc" + | "asynchat" + | "asyncore" + | "audioop" + | "binhex" + | "cgi" + | "cgitb" + | "chunk" + | "crypt" + | "distutils" + | "formatter" + | "graphlib" + | "imghdr" + | "imp" + | "lib2to3" + | "mailcap" + | "msilib" + | "nis" + | "nntplib" + | "ossaudiodev" + | "parser" + | "pipes" + | "smtpd" + | "sndhdr" + | "spwd" + | "sre-compile" + | "sre-constants" + | "sre-parse" + | "sunau" + | "symbol" + | "telnetlib" + | "uu" + | "xdrlib" + | "zoneinfo" + ) | ( + 10, + "aifc" + | "asynchat" + | "asyncore" + | "audioop" + | "binhex" + | "cgi" + | "cgitb" + | "chunk" + | "crypt" + | "distutils" + | "graphlib" + | "imghdr" + | "imp" + | "lib2to3" + | "mailcap" + | "msilib" + | "nis" + | "nntplib" + | "ossaudiodev" + | "pipes" + | "smtpd" + | "sndhdr" + | "spwd" + | "sre-compile" + | "sre-constants" + | "sre-parse" + | "sunau" + | "telnetlib" + | "uu" + | "xdrlib" + | "xxlimited-35" + | "zoneinfo" + ) | ( + 11, + "aifc" + | "asynchat" + | "asyncore" + | "audioop" + | "cgi" + | "cgitb" + | "chunk" + | "crypt" + | "distutils" + | "graphlib" + | "imghdr" + | "imp" + | "lib2to3" + | "mailcap" + | "msilib" + | "nis" + | "nntplib" + | "ossaudiodev" + | "pipes" + | "smtpd" + | "sndhdr" + | "spwd" + | "sre-compile" + | "sre-constants" + | "sre-parse" + | "sunau" + | "telnetlib" + | "tomllib" + | "uu" + | "xdrlib" + | "xxlimited-35" + | "zoneinfo" + ) | ( + 12, + "aifc" + | "audioop" + | "cgi" + | "cgitb" + | "chunk" + | "crypt" + | "graphlib" + | "imghdr" + | "lib2to3" + | "mailcap" + | "msilib" + | "nis" + | "nntplib" + | "ossaudiodev" + | "pipes" + | "sndhdr" + | "spwd" + | "sre-compile" + | "sre-constants" + | "sre-parse" + | "sunau" + | "telnetlib" + | "tomllib" + | "uu" + | "xdrlib" + | "xxlimited-35" + | "zoneinfo" + ) | ( + 13, + "graphlib" + | "sre-compile" + | "sre-constants" + | "sre-parse" + | "tomllib" + | "xxlimited-35" + | "zoneinfo" + ) | ( + 14, + "annotationlib" + | "compression" + | "graphlib" + | "sre-compile" + | "sre-constants" + | "sre-parse" + | "tomllib" + | "xxlimited-35" + | "zoneinfo" + ) | ( + 15, + "annotationlib" + | "compression" + | "graphlib" + | "profiling" + | "tomllib" + | "xxlimited-35" + | "zoneinfo" + ) + ) +} diff --git a/crates/uv-static/src/lib.rs b/crates/uv-static/src/lib.rs index 47bedf3da2d95..9d34cfde8fac0 100644 --- a/crates/uv-static/src/lib.rs +++ b/crates/uv-static/src/lib.rs @@ -1,6 +1,8 @@ pub use env_vars::*; +pub use known_stdlib::*; mod env_vars; +mod known_stdlib; use std::borrow::Cow; diff --git a/crates/uv-test/Cargo.toml b/crates/uv-test/Cargo.toml index 851a218873a1d..c5e513e8995fb 100644 --- a/crates/uv-test/Cargo.toml +++ b/crates/uv-test/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-test" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-test/README.md b/crates/uv-test/README.md index 7ba2ed4de18e1..f20b99d370a69 100644 --- a/crates/uv-test/README.md +++ b/crates/uv-test/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-test). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-test). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-toml/Cargo.toml b/crates/uv-toml/Cargo.toml index c629018eddb9c..f38debfacdf46 100644 --- a/crates/uv-toml/Cargo.toml +++ b/crates/uv-toml/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-toml" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-toml/README.md b/crates/uv-toml/README.md index 4f45636118841..2be094b939fa8 100644 --- a/crates/uv-toml/README.md +++ b/crates/uv-toml/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-toml). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-toml). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-tool/Cargo.toml b/crates/uv-tool/Cargo.toml index 9270336a80162..e13dfbfa456ed 100644 --- a/crates/uv-tool/Cargo.toml +++ b/crates/uv-tool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-tool" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-tool/README.md b/crates/uv-tool/README.md index 734f1ffd50cf0..178902a9843fd 100644 --- a/crates/uv-tool/README.md +++ b/crates/uv-tool/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-tool). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-tool). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-torch/Cargo.toml b/crates/uv-torch/Cargo.toml index ffa22524d0494..1420009dbc19a 100644 --- a/crates/uv-torch/Cargo.toml +++ b/crates/uv-torch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-torch" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-torch/README.md b/crates/uv-torch/README.md index 0668438303b49..e8440504abcc1 100644 --- a/crates/uv-torch/README.md +++ b/crates/uv-torch/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-torch). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-torch). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-trampoline-builder/Cargo.toml b/crates/uv-trampoline-builder/Cargo.toml index 5e81b7a5188a9..ad76d260f85f7 100644 --- a/crates/uv-trampoline-builder/Cargo.toml +++ b/crates/uv-trampoline-builder/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-trampoline-builder" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } diff --git a/crates/uv-trampoline-builder/README.md b/crates/uv-trampoline-builder/README.md index d94ce0fc8422e..c86b1783b6cf2 100644 --- a/crates/uv-trampoline-builder/README.md +++ b/crates/uv-trampoline-builder/README.md @@ -5,9 +5,9 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The source can be found -[here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-trampoline-builder). +[here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-trampoline-builder). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-trampoline-builder/trampolines/uv-trampoline-aarch64-console.exe b/crates/uv-trampoline-builder/trampolines/uv-trampoline-aarch64-console.exe index bd28e2ce3793e..30cf462410f50 100755 Binary files a/crates/uv-trampoline-builder/trampolines/uv-trampoline-aarch64-console.exe and b/crates/uv-trampoline-builder/trampolines/uv-trampoline-aarch64-console.exe differ diff --git a/crates/uv-trampoline-builder/trampolines/uv-trampoline-aarch64-gui.exe b/crates/uv-trampoline-builder/trampolines/uv-trampoline-aarch64-gui.exe index 8d69fbcb5c3ef..4056da1eee7b1 100755 Binary files a/crates/uv-trampoline-builder/trampolines/uv-trampoline-aarch64-gui.exe and b/crates/uv-trampoline-builder/trampolines/uv-trampoline-aarch64-gui.exe differ diff --git a/crates/uv-trampoline-builder/trampolines/uv-trampoline-i686-console.exe b/crates/uv-trampoline-builder/trampolines/uv-trampoline-i686-console.exe index 9c7f03ea7270e..233ca79777b48 100755 Binary files a/crates/uv-trampoline-builder/trampolines/uv-trampoline-i686-console.exe and b/crates/uv-trampoline-builder/trampolines/uv-trampoline-i686-console.exe differ diff --git a/crates/uv-trampoline-builder/trampolines/uv-trampoline-i686-gui.exe b/crates/uv-trampoline-builder/trampolines/uv-trampoline-i686-gui.exe index 1e8b75f80ba1e..e99f595a8e7f1 100755 Binary files a/crates/uv-trampoline-builder/trampolines/uv-trampoline-i686-gui.exe and b/crates/uv-trampoline-builder/trampolines/uv-trampoline-i686-gui.exe differ diff --git a/crates/uv-trampoline-builder/trampolines/uv-trampoline-x86_64-console.exe b/crates/uv-trampoline-builder/trampolines/uv-trampoline-x86_64-console.exe index d2d9793e2e37e..b56b14f27b9a3 100755 Binary files a/crates/uv-trampoline-builder/trampolines/uv-trampoline-x86_64-console.exe and b/crates/uv-trampoline-builder/trampolines/uv-trampoline-x86_64-console.exe differ diff --git a/crates/uv-trampoline-builder/trampolines/uv-trampoline-x86_64-gui.exe b/crates/uv-trampoline-builder/trampolines/uv-trampoline-x86_64-gui.exe index 640e5735cf20e..572f00676b8dc 100755 Binary files a/crates/uv-trampoline-builder/trampolines/uv-trampoline-x86_64-gui.exe and b/crates/uv-trampoline-builder/trampolines/uv-trampoline-x86_64-gui.exe differ diff --git a/crates/uv-trampoline/Cargo.lock b/crates/uv-trampoline/Cargo.lock index 7f57b86c8158e..1d90593167b88 100644 --- a/crates/uv-trampoline/Cargo.lock +++ b/crates/uv-trampoline/Cargo.lock @@ -138,7 +138,7 @@ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "uv-macros" -version = "0.0.49" +version = "0.0.50" dependencies = [ "proc-macro2", "quote", @@ -148,7 +148,7 @@ dependencies = [ [[package]] name = "uv-static" -version = "0.0.49" +version = "0.0.50" dependencies = [ "thiserror", "uv-macros", @@ -169,7 +169,7 @@ dependencies = [ [[package]] name = "uv-windows" -version = "0.0.49" +version = "0.0.50" dependencies = [ "windows", ] diff --git a/crates/uv-types/Cargo.toml b/crates/uv-types/Cargo.toml index caf1f781248c9..f5d0672cbfc43 100644 --- a/crates/uv-types/Cargo.toml +++ b/crates/uv-types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-types" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-types/README.md b/crates/uv-types/README.md index b4bdca5cb3801..e2383f2a2991e 100644 --- a/crates/uv-types/README.md +++ b/crates/uv-types/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-types). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-types). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-unix/Cargo.toml b/crates/uv-unix/Cargo.toml index 74289e9660029..2810a07b64690 100644 --- a/crates/uv-unix/Cargo.toml +++ b/crates/uv-unix/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-unix" -version = "0.0.49" +version = "0.0.50" description = "Unix-specific functionality for uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-unix/README.md b/crates/uv-unix/README.md index 63d156d303f57..a82d987c27020 100644 --- a/crates/uv-unix/README.md +++ b/crates/uv-unix/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-unix). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-unix). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-version/Cargo.toml b/crates/uv-version/Cargo.toml index 642e82f2e3c99..a6901bf24e77a 100644 --- a/crates/uv-version/Cargo.toml +++ b/crates/uv-version/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-version" -version = "0.11.16" +version = "0.11.17" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv-version/README.md b/crates/uv-version/README.md index 8011ad4ecc1f7..72f85c32cbe3d 100644 --- a/crates/uv-version/README.md +++ b/crates/uv-version/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.11.16) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-version). +This version (0.11.17) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-version). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-virtualenv/Cargo.toml b/crates/uv-virtualenv/Cargo.toml index cb93d99b770cc..0fbd9b81b238d 100644 --- a/crates/uv-virtualenv/Cargo.toml +++ b/crates/uv-virtualenv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-virtualenv" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" keywords = ["virtualenv", "venv", "python"] @@ -27,6 +27,7 @@ uv-pypi-types = { workspace = true } uv-python = { workspace = true } uv-shell = { workspace = true } uv-version = { workspace = true } +uv-warnings = { workspace = true } console = { workspace = true } fs-err = { workspace = true } diff --git a/crates/uv-virtualenv/src/lib.rs b/crates/uv-virtualenv/src/lib.rs index e6d2b38778c21..1eac4eeca0c66 100644 --- a/crates/uv-virtualenv/src/lib.rs +++ b/crates/uv-virtualenv/src/lib.rs @@ -6,7 +6,7 @@ use thiserror::Error; use uv_fs::Simplified; use uv_python::{Interpreter, PythonEnvironment}; -pub use virtualenv::{OnExisting, RemovalReason}; +pub use virtualenv::{ClearNonVirtualenv, OnExisting, RemovalReason}; mod virtualenv; @@ -27,6 +27,11 @@ pub enum Error { /// The path to the existing environment. path: PathBuf, }, + #[error("uv will not clear a directory that is not a virtual environment")] + ClearNonVirtualenv { + /// The non-virtual environment directory that would have been cleared. + path: PathBuf, + }, } impl uv_errors::Hint for Error { @@ -35,6 +40,9 @@ impl uv_errors::Hint for Error { Self::Exists { name, .. } => uv_errors::Hints::from(format!( "Use the `--clear` flag or set `UV_VENV_CLEAR=1` to replace the existing {name}", )), + Self::ClearNonVirtualenv { .. } => uv_errors::Hints::from( + "Use the `--force` flag to remove the existing directory anyway", + ), _ => uv_errors::Hints::none(), } } diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index b41d0667ff0a7..0fe434f37e3e4 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -22,6 +22,7 @@ use uv_python::managed::{ use uv_python::{Interpreter, VirtualEnvironment}; use uv_shell::escape_posix_for_single_quotes; use uv_version::version; +use uv_warnings::warn_user_once; /// Activation scripts for the environment, with dependent paths templated out. const ACTIVATE_TEMPLATES: &[(&str, &str)] = &[ @@ -126,6 +127,28 @@ pub(crate) fn create( debug!("Allowing existing {name} due to `--allow-existing`"); } OnExisting::Remove(reason) => { + if !is_virtualenv + && let RemovalReason::UserRequest(clear_non_virtualenv) = reason + { + match clear_non_virtualenv { + ClearNonVirtualenv::Allow => {} + ClearNonVirtualenv::Warn => { + warn_user_once!( + "The `--clear` option will remove the existing directory at `{}` \ + even though it is not a virtual environment. \ + This will become an error in a future release. \ + Use `--force` to suppress this warning, or \ + `--preview-features venv-safe-clear` to error on this now.", + location.user_display() + ); + } + ClearNonVirtualenv::Error => { + return Err(Error::ClearNonVirtualenv { + path: location.to_path_buf(), + }); + } + } + } debug!("Removing existing {name} ({reason})"); // Before removing the virtual environment, we need to canonicalize the path // because `Path::metadata` will follow the symlink but we're still operating on @@ -601,10 +624,20 @@ fn confirm_clear(location: &Path, name: &'static str) -> Result, io } } +#[derive(Debug, Copy, Clone, Eq, PartialEq)] +pub enum ClearNonVirtualenv { + /// Allow clearing a non-virtual environment directory. + Allow, + /// Warn before clearing a non-virtual environment directory. + Warn, + /// Refuse to clear a non-virtual environment directory. + Error, +} + #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub enum RemovalReason { /// The removal was explicitly requested, i.e., with `--clear`. - UserRequest, + UserRequest(ClearNonVirtualenv), /// The environment can be removed because it is considered temporary, e.g., a build /// environment. TemporaryEnvironment, @@ -616,7 +649,7 @@ pub enum RemovalReason { impl std::fmt::Display for RemovalReason { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::UserRequest => f.write_str("requested with `--clear`"), + Self::UserRequest(_) => f.write_str("requested with `--clear`"), Self::ManagedEnvironment => f.write_str("environment is managed by uv"), Self::TemporaryEnvironment => f.write_str("environment is temporary"), } @@ -640,11 +673,16 @@ pub enum OnExisting { } impl OnExisting { - pub fn from_args(allow_existing: bool, clear: bool, no_clear: bool) -> Self { + pub fn from_args( + allow_existing: bool, + clear: bool, + no_clear: bool, + clear_non_virtualenv: ClearNonVirtualenv, + ) -> Self { if allow_existing { Self::Allow } else if clear { - Self::Remove(RemovalReason::UserRequest) + Self::Remove(RemovalReason::UserRequest(clear_non_virtualenv)) } else if no_clear { Self::Fail } else { diff --git a/crates/uv-warnings/Cargo.toml b/crates/uv-warnings/Cargo.toml index cb0d54cfef96f..4318c718f3537 100644 --- a/crates/uv-warnings/Cargo.toml +++ b/crates/uv-warnings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-warnings" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } @@ -16,11 +16,12 @@ doctest = false workspace = true [dependencies] +uv-errors = { workspace = true } + anstream = { workspace = true } owo-colors = { workspace = true } rustc-hash = { workspace = true } [dev-dependencies] anyhow = { workspace = true } -indoc = { workspace = true } insta = { workspace = true } diff --git a/crates/uv-warnings/README.md b/crates/uv-warnings/README.md index c8ab7b5fd3ba9..5de2afcaba537 100644 --- a/crates/uv-warnings/README.md +++ b/crates/uv-warnings/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-warnings). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-warnings). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-warnings/src/lib.rs b/crates/uv-warnings/src/lib.rs index 761f4839fd8a1..fc410fdc215d5 100644 --- a/crates/uv-warnings/src/lib.rs +++ b/crates/uv-warnings/src/lib.rs @@ -1,5 +1,5 @@ use std::error::Error; -use std::iter; +use std::fmt; use std::sync::atomic::AtomicBool; use std::sync::{LazyLock, Mutex}; @@ -8,8 +8,8 @@ use std::sync::{LazyLock, Mutex}; pub use anstream; #[doc(hidden)] pub use owo_colors; -use owo_colors::{DynColor, OwoColorize}; use rustc_hash::FxHashSet; +use uv_errors::{ErrorOptions, write_error_chain_with_options}; /// Whether user-facing warnings are enabled. pub static ENABLED: AtomicBool = AtomicBool::new(false); @@ -24,6 +24,23 @@ pub fn disable() { ENABLED.store(false, std::sync::atomic::Ordering::Relaxed); } +/// Format a warning chain to standard error. +pub fn write_warning_chain(err: &dyn Error) -> fmt::Result { + write_warning_chain_with_options(err, ErrorOptions::default()) +} + +fn write_warning_chain_with_options( + err: &dyn Error, + options: ErrorOptions<'_, C, W>, +) -> fmt::Result { + write_error_chain_with_options( + err, + options + .with_level("warning") + .with_color(owo_colors::AnsiColors::Yellow), + ) +} + /// Warn a user, if warnings are enabled. #[macro_export] macro_rules! warn_user { @@ -60,99 +77,27 @@ macro_rules! warn_user_once { }}; } -/// Format an error or warning chain. -/// -/// # Example -/// -/// ```text -/// error: Failed to install app -/// Caused By: Failed to install dependency -/// Caused By: Error writing failed `/home/ferris/deps/foo`: Permission denied -/// ``` -/// -/// ```text -/// warning: Failed to create registry entry for Python 3.12 -/// Caused By: Security policy forbids chaining registry entries -/// ``` -/// -/// ```text -/// error: Failed to download Python 3.12 -/// Caused by: Failed to fetch https://example.com/upload/python3.13.tar.zst -/// Server says: This endpoint only support POST requests. -/// -/// For downloads, please refer to https://example.com/download/python3.13.tar.zst -/// Caused by: Caused By: HTTP Error 400 -/// ``` -pub fn write_error_chain( - err: &dyn Error, - mut stream: impl std::fmt::Write, - level: impl AsRef, - color: impl DynColor + Copy, -) -> std::fmt::Result { - writeln!( - &mut stream, - "{}{} {}", - level.as_ref().color(color).bold(), - ":".bold(), - err.to_string().trim().bold() - )?; - for source in iter::successors(err.source(), |&err| err.source()) { - let msg = source.to_string(); - let mut lines = msg.lines(); - if let Some(first) = lines.next() { - let padding = " "; - let cause = "Caused by"; - let child_padding = " ".repeat(padding.len() + cause.len() + 2); - writeln!( - &mut stream, - "{}{}: {}", - padding, - cause.color(color).bold(), - first.trim() - )?; - for line in lines { - let line = line.trim_end(); - if line.is_empty() { - // Avoid showing indents on empty lines - writeln!(&mut stream)?; - } else { - writeln!(&mut stream, "{}{}", child_padding, line.trim_end())?; - } - } - } - } - Ok(()) -} - #[cfg(test)] mod tests { - use crate::write_error_chain; use anyhow::anyhow; - use indoc::indoc; use insta::assert_snapshot; - use owo_colors::AnsiColors; - - #[test] - fn format_multiline_message() { - let err_middle = indoc! {"Failed to fetch https://example.com/upload/python3.13.tar.zst - Server says: This endpoint only support POST requests. - - For downloads, please refer to https://example.com/download/python3.13.tar.zst"}; - let err = anyhow!("Caused By: HTTP Error 400") - .context(err_middle) - .context("Failed to download Python 3.12"); + use uv_errors::ErrorOptions; - let mut rendered = String::new(); - write_error_chain(err.as_ref(), &mut rendered, "error", AnsiColors::Red).unwrap(); - let rendered = anstream::adapter::strip_str(&rendered); + use super::write_warning_chain_with_options; - assert_snapshot!(rendered, @" - error: Failed to download Python 3.12 - Caused by: Failed to fetch https://example.com/upload/python3.13.tar.zst - Server says: This endpoint only support POST requests. - - For downloads, please refer to https://example.com/download/python3.13.tar.zst - Caused by: Caused By: HTTP Error 400 - "); + #[test] + fn format_warning_chain() { + let error = anyhow!("Failed to create registry entry"); + let mut output = String::new(); + write_warning_chain_with_options( + error.as_ref(), + ErrorOptions::default().with_stream(&mut output), + ) + .unwrap(); + assert_snapshot!(format!("{output:?}"), @r#""\u{1b}[1m\u{1b}[33mwarning\u{1b}[39m\u{1b}[0m\u{1b}[1m:\u{1b}[0m Failed to create registry entry\n""#); + let output = anstream::adapter::strip_str(&output); + + assert_snapshot!(output, @"warning: Failed to create registry entry +"); } } diff --git a/crates/uv-windows/Cargo.toml b/crates/uv-windows/Cargo.toml index 2658da7cf15b8..7c8d4f7f4c0fc 100644 --- a/crates/uv-windows/Cargo.toml +++ b/crates/uv-windows/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-windows" -version = "0.0.49" +version = "0.0.50" edition = { workspace = true } rust-version = { workspace = true } homepage = { workspace = true } diff --git a/crates/uv-windows/README.md b/crates/uv-windows/README.md index 70469cadb25c9..ff1bd6407f74f 100644 --- a/crates/uv-windows/README.md +++ b/crates/uv-windows/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-windows). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-windows). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index 29b85ff25dab5..fb559a0fd9fce 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv-workspace" -version = "0.0.49" +version = "0.0.50" description = "This is an internal component crate of uv" edition = { workspace = true } rust-version = { workspace = true } @@ -58,6 +58,7 @@ tempfile = { workspace = true } schemars = [ "dep:schemars", "uv-build-backend/schemars", + "uv-distribution-types/schemars", "uv-fs/schemars", "uv-normalize/schemars", "uv-pep508/schemars", diff --git a/crates/uv-workspace/README.md b/crates/uv-workspace/README.md index b811b23001039..7f9b5da99b73f 100644 --- a/crates/uv-workspace/README.md +++ b/crates/uv-workspace/README.md @@ -5,8 +5,8 @@ This crate is an internal component of [uv](https://crates.io/crates/uv). The Rust API exposed here is unstable and will have frequent breaking changes. -This version (0.0.49) is a component of [uv 0.11.16](https://crates.io/crates/uv/0.11.16). The -source can be found [here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv-workspace). +This version (0.0.50) is a component of [uv 0.11.17](https://crates.io/crates/uv/0.11.17). The +source can be found [here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv-workspace). See uv's [crate versioning policy](https://docs.astral.sh/uv/reference/policies/versioning/#crate-versioning) diff --git a/crates/uv/Cargo.toml b/crates/uv/Cargo.toml index b69846beb331a..c45bb5dd5466a 100644 --- a/crates/uv/Cargo.toml +++ b/crates/uv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uv" -version = "0.11.16" +version = "0.11.17" description = "A Python package and project manager" edition = { workspace = true } rust-version = { workspace = true } diff --git a/crates/uv/README.md b/crates/uv/README.md index bede6f501fb7a..edbe02ceab975 100644 --- a/crates/uv/README.md +++ b/crates/uv/README.md @@ -10,8 +10,8 @@ for more information. This crate is the entry point to the uv command-line interface. The Rust API exposed here is not considered public interface. -This is version 0.11.16. The source can be found -[here](https://github.com/astral-sh/uv/blob/0.11.16/crates/uv). +This is version 0.11.17. The source can be found +[here](https://github.com/astral-sh/uv/blob/0.11.17/crates/uv). The following uv workspace members are also available: diff --git a/crates/uv/src/commands/diagnostics.rs b/crates/uv/src/commands/diagnostics.rs index 9f587c4d5e30c..4cab9d72b866c 100644 --- a/crates/uv/src/commands/diagnostics.rs +++ b/crates/uv/src/commands/diagnostics.rs @@ -41,8 +41,8 @@ static SUGGESTIONS: LazyLock> = LazyLock::ne /// installation. #[derive(Debug, Default)] pub(crate) struct OperationDiagnostic { - /// A caller-provided hint to render after the error output. - hint: Option, + /// Caller-provided hints to render after the error output. + hints: Vec, /// Whether system certificates are being used. pub(crate) system_certs: bool, /// The context to display to the user upon resolution failure. @@ -59,13 +59,11 @@ impl OperationDiagnostic { } } - /// Set the hint to display to the user upon resolution failure. + /// Add a hint to display to the user upon resolution failure. #[must_use] - pub(crate) fn with_hint(self, hint: String) -> Self { - Self { - hint: Some(hint), - ..self - } + pub(crate) fn with_hint(mut self, hint: String) -> Self { + self.hints.push(hint); + self } /// Set the context to display to the user upon resolution failure. @@ -140,12 +138,10 @@ impl OperationDiagnostic { err => Some(err), }; - // Render the caller-provided hint after the error output. + // Render the caller-provided hints after the error output. if result.is_none() { - if let Some(hint) = &self.hint { - let hints = Hints::from(hint.as_str()); - anstream::eprint!("{hints}"); - } + let hints: Hints<'_> = self.hints.into_iter().collect(); + anstream::eprint!("{hints}"); } result diff --git a/crates/uv/src/commands/editable.rs b/crates/uv/src/commands/editable.rs index 79d1aa7fa8768..a92a1d27a719d 100644 --- a/crates/uv/src/commands/editable.rs +++ b/crates/uv/src/commands/editable.rs @@ -3,69 +3,44 @@ use std::sync::Arc; use uv_configuration::EditableMode; use uv_distribution_types::{DirectorySourceDist, Dist, Resolution, ResolvedDist, SourceDist}; -/// If necessary, convert editable distributions to non-editable. +/// Apply editable installation overrides to local directory distributions. pub(crate) fn apply_editable_mode( resolution: Resolution, editable: Option, ) -> Resolution { - match editable { - // No modifications are necessary for editable mode; retain any editable distributions. - None => resolution, + let Some(editable) = editable else { + return resolution; + }; - // Filter out any non-editable distributions. - Some(EditableMode::Editable) => resolution.map(|dist| { - let ResolvedDist::Installable { dist, version } = dist else { - return None; - }; - let Dist::Source(SourceDist::Directory(DirectorySourceDist { - name, - install_path, - editable: None | Some(false), - r#virtual, - url, - })) = dist.as_ref() - else { - return None; - }; + resolution.map(|dist| { + let ResolvedDist::Installable { dist, version } = dist else { + return None; + }; + let Dist::Source(SourceDist::Directory(DirectorySourceDist { + name, + install_path, + editable: current_editable, + r#virtual, + url, + })) = dist.as_ref() + else { + return None; + }; - Some(ResolvedDist::Installable { - dist: Arc::new(Dist::Source(SourceDist::Directory(DirectorySourceDist { - name: name.clone(), - install_path: install_path.clone(), - editable: Some(true), - r#virtual: *r#virtual, - url: url.clone(), - }))), - version: version.clone(), - }) - }), + let editable = editable.for_package(name)?; + if *current_editable == Some(editable) { + return None; + } - // If a package is editable, map it to a non-editable distribution. - Some(EditableMode::NonEditable) => resolution.map(|dist| { - let ResolvedDist::Installable { dist, version } = dist else { - return None; - }; - let Dist::Source(SourceDist::Directory(DirectorySourceDist { - name, - install_path, - editable: None | Some(true), - r#virtual, - url, - })) = dist.as_ref() - else { - return None; - }; - - Some(ResolvedDist::Installable { - dist: Arc::new(Dist::Source(SourceDist::Directory(DirectorySourceDist { - name: name.clone(), - install_path: install_path.clone(), - editable: Some(false), - r#virtual: *r#virtual, - url: url.clone(), - }))), - version: version.clone(), - }) - }), - } + Some(ResolvedDist::Installable { + dist: Arc::new(Dist::Source(SourceDist::Directory(DirectorySourceDist { + name: name.clone(), + install_path: install_path.clone(), + editable: Some(editable), + r#virtual: *r#virtual, + url: url.clone(), + }))), + version: version.clone(), + }) + }) } diff --git a/crates/uv/src/commands/mod.rs b/crates/uv/src/commands/mod.rs index 62df3cc29c79f..dd8e13d17c076 100644 --- a/crates/uv/src/commands/mod.rs +++ b/crates/uv/src/commands/mod.rs @@ -5,9 +5,10 @@ use std::time::Duration; use std::{fmt::Write, process::ExitCode}; use anstream::AutoStream; -use anyhow::Context; +use anyhow::{Context, bail}; use owo_colors::OwoColorize; use tracing::debug; +use uv_warnings::warn_user; pub(crate) use auth::dir::dir as auth_dir; pub(crate) use auth::helper::helper as auth_helper; @@ -112,6 +113,94 @@ pub(crate) enum ExitStatus { External(u8), } +/// Read dotenv files into an overlay for a spawned process. +/// +/// These values intentionally do not mutate uv's process environment and cannot mutate +/// the current uv process' settings. +fn read_env_files<'a>( + env_file: impl DoubleEndedIterator, +) -> anyhow::Result> { + let mut environment = Vec::new(); + + for env_file_path in env_file.rev().map(PathBuf::as_path) { + let iter = match dotenvy::from_path_iter(env_file_path) { + Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { + bail!( + "No environment file found at: `{}`", + env_file_path.simplified_display() + ); + } + Err(dotenvy::Error::Io(err)) => { + bail!( + "Failed to read environment file `{}`: {err}", + env_file_path.simplified_display() + ); + } + Err(dotenvy::Error::LineParse(content, position)) => { + warn_user!( + "Failed to parse environment file `{}` at position {position}: {content}", + env_file_path.simplified_display(), + ); + continue; + } + Err(err) => { + warn_user!( + "Failed to parse environment file `{}`: {err}", + env_file_path.simplified_display(), + ); + continue; + } + Ok(iter) => iter, + }; + + let mut parsed = true; + for item in iter { + match item { + Ok((key, value)) => { + if std::env::var(&key).is_err() { + environment.push((key, value)); + } + } + Err(dotenvy::Error::Io(err)) => { + bail!( + "Failed to read environment file `{}`: {err}", + env_file_path.simplified_display() + ); + } + Err(dotenvy::Error::LineParse(content, position)) => { + warn_user!( + "Failed to parse environment file `{}` at position {position}: {content}", + env_file_path.simplified_display(), + ); + parsed = false; + break; + } + Err(err) => { + warn_user!( + "Failed to parse environment file `{}`: {err}", + env_file_path.simplified_display(), + ); + parsed = false; + break; + } + } + } + + if parsed { + debug!( + "Read environment file at: `{}`", + env_file_path.simplified_display() + ); + } + } + + // `dotenvy::from_path` preserves the first loaded value, while `Command::envs` preserves the + // last value set for the child process. + environment.reverse(); + + Ok(environment) +} + impl From for ExitCode { fn from(status: ExitStatus) -> Self { match status { diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 8eb5b4f907495..5f8f08359bfd9 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -17,7 +17,8 @@ use uv_cache_key::RepositoryUrl; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DevMode, DryRun, - ExtrasSpecification, ExtrasSpecificationWithDefaults, GitLfsSetting, InstallOptions, NoSources, + EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, GitLfsSetting, + InstallOptions, NoSources, }; use uv_dispatch::BuildDispatch; use uv_distribution::{DistributionDatabase, LoweredExtraBuildDependencies}; @@ -36,6 +37,7 @@ use uv_requirements::{NamedRequirementsResolver, RequirementsSource, Requirement use uv_resolver::FlatIndex; use uv_scripts::{Pep723Metadata, Pep723Script}; use uv_settings::{MalwareCheckSettings, PythonInstallMirrors}; +use uv_static::is_known_standard_library_package; use uv_types::{BuildIsolation, HashStrategy, SourceTreeEditablePolicy}; use uv_warnings::warn_user_once; use uv_workspace::pyproject::{ @@ -79,7 +81,7 @@ pub(crate) async fn add( requirements: Vec, constraints: Vec, marker: Option, - editable: Option, + editable: Option, dependency_type: DependencyType, raw: bool, bounds: Option, @@ -635,7 +637,7 @@ pub(crate) async fn add( let edits = edits( requirements, &target, - editable, + editable.as_ref(), &dependency_type, raw, rev.as_deref(), @@ -736,6 +738,7 @@ pub(crate) async fn add( // Use separate state for locking and syncing. let lock_state = state.fork(); let sync_state = state; + let python_minor = target.interpreter().python_minor(); match Box::pin(lock_and_sync( target, @@ -775,19 +778,63 @@ pub(crate) async fn add( let _ = snapshot.revert(); } match err { - ProjectError::Operation(err) => diagnostics::OperationDiagnostic::with_system_certs(client_builder.system_certs()).with_hint(format!("If you want to add the package regardless of the failed resolution, provide the `{}` flag to skip locking and syncing", "--frozen".green())) - .report(err) - .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())), + ProjectError::Operation(err) => { + let standard_library_hint = standard_library_hint(&err, &edits, python_minor); + let diagnostic = diagnostics::OperationDiagnostic::with_system_certs( + client_builder.system_certs(), + ); + let diagnostic = if let Some(hint) = standard_library_hint { + diagnostic.with_hint(hint) + } else { + diagnostic + }; + diagnostic + .with_hint(format!("If you want to add the package regardless of the failed resolution, provide the `{}` flag to skip locking and syncing", "--frozen".green())) + .report(err) + .map_or(Ok(ExitStatus::Failure), |err| Err(err.into())) + } err => Err(err.into()), } } } } +fn standard_library_hint( + operation_error: &crate::commands::pip::operations::Error, + edits: &[DependencyEdit], + python_minor: u8, +) -> Option { + let crate::commands::pip::operations::Error::Resolve(uv_resolver::ResolveError::NoSolution( + no_solution_error, + )) = operation_error + else { + return None; + }; + + edits.iter().find_map(|edit| { + if edit + .source + .as_ref() + .is_none_or(|source| matches!(source, Source::Registry { .. })) + && is_known_standard_library_package(python_minor, edit.requirement.name.as_ref()) + && no_solution_error + .packages() + .any(|package| package == &edit.requirement.name) + { + Some(format!( + "The module `{}` is included in the Python standard library and usually should not be added as a dependency", + edit.requirement.name + )) + } else { + None + } + }) +} + fn edits( requirements: Vec, target: &AddTarget, - editable: Option, + editable: Option<&EditableMode>, dependency_type: &DependencyType, raw: bool, rev: Option<&str>, @@ -800,6 +847,8 @@ fn edits( ) -> Result> { let mut edits = Vec::::with_capacity(requirements.len()); for mut requirement in requirements { + let editable = editable.and_then(|editable| editable.for_package(&requirement.name)); + // Add the specified extras. let mut ex = requirement.extras.to_vec(); ex.extend(extras.iter().cloned()); diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 11d989dfa1292..29e101fed3c3e 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -393,7 +393,7 @@ pub(crate) async fn export( &extras, &groups, include_annotations, - editable, + editable.as_ref(), &install_options, )?; diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 1244bf44f0fb7..50cd23cf051c0 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -70,7 +70,7 @@ pub(crate) mod environment; pub(crate) mod export; pub(crate) mod format; pub(crate) mod init; -mod install_target; +pub(crate) mod install_target; pub(crate) mod lock; pub(crate) mod lock_target; pub(crate) mod remove; @@ -717,7 +717,7 @@ impl ScriptInterpreter { .path .file_stem() .and_then(|name| name.to_str()) - .and_then(|name| cache_name(name, None)) + .and_then(|name| cache_name(name, Some(100))) { format!("{file_name}-{digest}") } else { @@ -1402,7 +1402,7 @@ impl ScriptPython { /// The Python environment for a project. #[derive(Debug)] -enum ProjectEnvironment { +pub(crate) enum ProjectEnvironment { /// An existing [`PythonEnvironment`] was discovered, which satisfies the project's requirements. Existing(PythonEnvironment), /// An existing [`PythonEnvironment`] was discovered, but did not satisfy the project's diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 7d43ae3c0e930..6fb75bfc9b2ff 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -74,7 +74,7 @@ use crate::commands::project::{ update_environment, validate_project_requires_python, }; use crate::commands::reporters::PythonDownloadReporter; -use crate::commands::{ExitStatus, diagnostics, project}; +use crate::commands::{ExitStatus, diagnostics, project, read_env_files}; use crate::printer::Printer; use crate::settings::{ FrozenSource, GlobalSettings, LockCheck, LockCheckSource, ResolverInstallerSettings, @@ -165,41 +165,7 @@ pub(crate) async fn run( let lock_state = UniversalState::default(); let sync_state = lock_state.fork(); - // Read from the `.env` file, if necessary. - for env_file_path in env_file.iter().rev().map(PathBuf::as_path) { - match dotenvy::from_path(env_file_path) { - Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { - bail!( - "No environment file found at: `{}`", - env_file_path.simplified_display() - ); - } - Err(dotenvy::Error::Io(err)) => { - bail!( - "Failed to read environment file `{}`: {err}", - env_file_path.simplified_display() - ); - } - Err(dotenvy::Error::LineParse(content, position)) => { - warn_user!( - "Failed to parse environment file `{}` at position {position}: {content}", - env_file_path.simplified_display(), - ); - } - Err(err) => { - warn_user!( - "Failed to parse environment file `{}`: {err}", - env_file_path.simplified_display(), - ); - } - Ok(()) => { - debug!( - "Read environment file at: `{}`", - env_file_path.simplified_display() - ); - } - } - } + let env_file_environment = read_env_files(env_file.iter())?; // Initialize any output reporters. let download_reporter = PythonDownloadReporter::single(printer); @@ -319,7 +285,7 @@ pub(crate) async fn run( &environment, &extras.with_defaults(DefaultExtras::default()), &groups.with_defaults(DefaultGroups::default()), - editable, + editable.clone(), install_options, modifications, python_platform.as_ref(), @@ -1298,6 +1264,7 @@ pub(crate) async fn run( debug!("Running `{command}`"); let mut process = command.as_command(interpreter); + process.envs(env_file_environment); // Construct the `PATH` environment variable. let new_path = std::env::join_paths( diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index dce812ae0172c..412fd2651ff2e 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -633,7 +633,7 @@ impl Deref for SyncEnvironment { } /// Sync a lockfile with an environment. -pub(super) async fn do_sync( +pub(crate) async fn do_sync( target: InstallTarget<'_>, venv: &PythonEnvironment, extras: &ExtrasSpecificationWithDefaults, diff --git a/crates/uv/src/commands/publish.rs b/crates/uv/src/commands/publish.rs index 84e424993409e..eb428db8c0939 100644 --- a/crates/uv/src/commands/publish.rs +++ b/crates/uv/src/commands/publish.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::{Context, Result, bail}; use console::Term; -use owo_colors::{AnsiColors, OwoColorize}; +use owo_colors::OwoColorize; use tokio::sync::Semaphore; use tracing::{debug, info, trace}; use uv_auth::{Credentials, PyxTokenStore}; @@ -13,6 +13,7 @@ use uv_client::{ }; use uv_configuration::{KeyringProviderType, TrustedPublishing}; use uv_distribution_types::{IndexCapabilities, IndexLocations, IndexUrl}; +use uv_errors::{ErrorOptions, write_error_chain_with_options}; use uv_preview::{Preview, PreviewFeature}; use uv_publish::{ CheckUrlClient, FormMetadata, PublishError, TrustedPublishResult, check_trusted_publishing, @@ -20,7 +21,7 @@ use uv_publish::{ }; use uv_redacted::DisplaySafeUrl; use uv_settings::EnvironmentOptions; -use uv_warnings::{warn_user_once, write_error_chain}; +use uv_warnings::warn_user_once; use crate::commands::reporters::PublishReporter; use crate::commands::{ExitStatus, human_readable_bytes}; @@ -240,7 +241,10 @@ pub(crate) async fn publish( Ok(false) => {} Err(err) => { if dry_run { - write_error_chain(&err, printer.stderr(), "error", AnsiColors::Red)?; + write_error_chain_with_options( + &err, + ErrorOptions::default().with_stream(printer.stderr()), + )?; error_count += 1; continue; } @@ -278,7 +282,10 @@ pub(crate) async fn publish( Ok(metadata) => metadata, Err(err) => { if dry_run { - write_error_chain(&err, printer.stderr(), "error", AnsiColors::Red)?; + write_error_chain_with_options( + &err, + ErrorOptions::default().with_stream(printer.stderr()), + )?; error_count += 1; continue; } @@ -319,11 +326,9 @@ pub(crate) async fn publish( } Err(err) => { let err: anyhow::Error = err.into(); - write_error_chain( + write_error_chain_with_options( err.as_ref(), - printer.stderr(), - "error", - AnsiColors::Red, + ErrorOptions::default().with_stream(printer.stderr()), )?; error_count += 1; } @@ -382,11 +387,9 @@ pub(crate) async fn publish( Err(err) => { if dry_run { let err: anyhow::Error = err.into(); - write_error_chain( + write_error_chain_with_options( err.as_ref(), - printer.stderr(), - "error", - AnsiColors::Red, + ErrorOptions::default().with_stream(printer.stderr()), )?; error_count += 1; continue; @@ -544,13 +547,11 @@ async fn gather_credentials( )?; trace!("Error trace: {err:?}"); - write_error_chain( + write_error_chain_with_options( anyhow::Error::from(err) .context("Trusted publishing failed") .as_ref(), - printer.stderr(), - "error", - AnsiColors::Red, + ErrorOptions::default().with_stream(printer.stderr()), )?; } } diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index a00df54dc06ba..991b8017dde28 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -17,6 +17,7 @@ use tracing::{debug, trace, warn}; use uv_cache::Cache; use uv_client::BaseClientBuilder; use uv_configuration::Concurrency; +use uv_errors::{ErrorOptions, write_error_chain_with_options}; use uv_fs::Simplified; use uv_platform::{Arch, Libc}; use uv_preview::{Preview, PreviewFeature}; @@ -35,7 +36,7 @@ use uv_python::{ }; use uv_shell::Shell; use uv_trampoline_builder::{Launcher, LauncherKind}; -use uv_warnings::{warn_user, write_error_chain}; +use uv_warnings::warn_user; use crate::commands::python::{ChangeEvent, ChangeEventKind}; use crate::commands::reporters::PythonDownloadReporter; @@ -914,11 +915,9 @@ async fn perform_install( { match kind { InstallErrorKind::DownloadUnpack => { - write_error_chain( + write_error_chain_with_options( err.context(format!("Failed to install {key}")).as_ref(), - printer.stderr(), - "error", - AnsiColors::Red, + ErrorOptions::default().with_stream(printer.stderr()), )?; } InstallErrorKind::Bin => { @@ -928,12 +927,13 @@ async fn perform_install( Some(true) => ("error", AnsiColors::Red), }; - write_error_chain( + write_error_chain_with_options( err.context(format!("Failed to install executable for {key}")) .as_ref(), - printer.stderr(), - level, - color, + ErrorOptions::default() + .with_level(level) + .with_color(color) + .with_stream(printer.stderr()), )?; } InstallErrorKind::Registry => { @@ -944,12 +944,13 @@ async fn perform_install( }; trace!("Error trace: {err:?}"); - write_error_chain( + write_error_chain_with_options( err.context(format!("Failed to create registry entry for {key}")) .as_ref(), - printer.stderr(), - level, - color, + ErrorOptions::default() + .with_level(level) + .with_color(color) + .with_stream(printer.stderr()), )?; } } diff --git a/crates/uv/src/commands/tool/common.rs b/crates/uv/src/commands/tool/common.rs index 063cd68b1c383..1429ae152a8da 100644 --- a/crates/uv/src/commands/tool/common.rs +++ b/crates/uv/src/commands/tool/common.rs @@ -12,18 +12,24 @@ use thiserror::Error; use tracing::{debug, warn}; use uv_cache::Cache; use uv_client::BaseClientBuilder; -use uv_distribution_types::{InstalledDist, Name, Requirement}; +use uv_configuration::GitLfsSetting; +use uv_distribution::StaticMetadataDatabase; +use uv_distribution_types::{ + InstalledDist, Name, Requirement, RequiresPython, UnresolvedRequirement, +}; use uv_errors::{ErrorWithHints, Hint, Hints}; -use uv_fs::Simplified; #[cfg(unix)] use uv_fs::replace_symlink; +use uv_fs::{CWD, Simplified}; +use uv_git::GitResolver; use uv_installer::SitePackages; use uv_normalize::PackageName; use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers}; use uv_preview::Preview; use uv_python::{ EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, - PythonPreference, PythonRequest, PythonVariant, VersionRequest, + PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, + VersionRequest, }; use uv_settings::{PythonInstallMirrors, ToolOptions}; use uv_shell::Shell; @@ -90,7 +96,7 @@ impl Hint for NoExecutablesError { hints } } -use crate::commands::project::ProjectError; +use crate::commands::project::{ProjectError, PythonRequestSource}; use crate::commands::reporters::PythonDownloadReporter; use crate::printer::Printer; @@ -133,6 +139,122 @@ pub(crate) fn remove_entrypoints(tool: &Tool) { } } +/// The resolved Python request for a tool invocation. +#[derive(Debug, Clone)] +pub(crate) struct ToolPython { + /// The source of the Python request. + source: PythonRequestSource, + /// The selected Python request, computed by considering an explicit request, a global + /// version file, and static `requires-python` metadata from the source requirement. + pub(crate) python_request: Option, +} + +impl ToolPython { + /// Determine the [`ToolPython`] request for a tool invocation. + pub(crate) async fn from_request( + python_request: Option, + requirement: Option<&UnresolvedRequirement>, + no_config: bool, + lfs: GitLfsSetting, + git_resolver: &GitResolver, + client_builder: &BaseClientBuilder<'_>, + cache: &Cache, + ) -> Result { + let requires_python = if python_request.is_none() { + match requirement { + Some(requirement) => { + infer_requires_python_from_requirement( + requirement, + lfs, + git_resolver, + client_builder, + cache, + ) + .await + } + None => None, + } + } else { + None + }; + + let (source, python_request) = if let Some(request) = python_request { + (PythonRequestSource::UserRequest, Some(request)) + } else if let Some(file) = PythonVersionFile::discover( + &*CWD, + &VersionFileDiscoveryOptions::default() + .with_no_config(no_config) + .with_no_local(true), + ) + .await? + .filter(|file| match (file.version(), requires_python.as_ref()) { + (Some(request), Some(requires_python)) => { + request.intersects_requires_python(requires_python) + } + _ => true, + }) { + ( + PythonRequestSource::DotPythonVersion(file.clone()), + file.version().cloned(), + ) + } else { + ( + PythonRequestSource::RequiresPython, + requires_python + .as_ref() + .and_then(PythonRequest::from_requires_python), + ) + }; + + if let Some(python_request) = python_request.as_ref() { + debug!( + "Using Python request `{}` from {source}", + python_request.to_canonical_string() + ); + } + + Ok(Self { + source, + python_request, + }) + } + + /// Returns `true` if the selected request was explicitly provided by the user. + pub(crate) fn is_explicit(&self) -> bool { + matches!(self.source, PythonRequestSource::UserRequest) + } +} + +/// Infer [`RequiresPython`] from a direct source requirement by reading its `pyproject.toml`. +/// +/// Returns `None` when the requirement is not a directory or Git source, its metadata is not +/// statically available, or the Git source cannot be fetched. +async fn infer_requires_python_from_requirement( + requirement: &UnresolvedRequirement, + lfs: GitLfsSetting, + git_resolver: &GitResolver, + client_builder: &BaseClientBuilder<'_>, + cache: &Cache, +) -> Option { + let requirement = requirement + .clone() + .augment_requirement(None, None, None, lfs.into(), None); + let source = requirement.source(); + + match StaticMetadataDatabase::new(client_builder, git_resolver, cache) + .requires_python(source.as_ref()) + .await + { + Ok(requires_python) => requires_python, + Err(err) => { + debug!( + "Failed to infer `requires-python` from source requirement (`{requirement}`): {err}" + ); + None + } + } +} + /// Given a no-solution error and the [`Interpreter`] that was used during the solve, attempt to /// discover an alternate [`Interpreter`] that satisfies the `requires-python` constraint. pub(crate) async fn refine_interpreter( diff --git a/crates/uv/src/commands/tool/install.rs b/crates/uv/src/commands/tool/install.rs index 60745b99d129f..b23ae3c05a6b6 100644 --- a/crates/uv/src/commands/tool/install.rs +++ b/crates/uv/src/commands/tool/install.rs @@ -16,7 +16,6 @@ use uv_distribution_types::{ ExtraBuildRequires, IndexCapabilities, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirementSpecification, }; -use uv_fs::CWD; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; @@ -24,7 +23,7 @@ use uv_pep508::MarkerTree; use uv_preview::Preview; use uv_python::{ EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, - PythonPreference, PythonRequest, PythonVersionFile, VersionFileDiscoveryOptions, + PythonPreference, PythonRequest, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; @@ -43,7 +42,7 @@ use crate::commands::project::{ sync_environment, update_environment, }; use crate::commands::tool::common::{ - finalize_tool_install, refine_interpreter, remove_entrypoints, + ToolPython, finalize_tool_install, refine_interpreter, remove_entrypoints, }; use crate::commands::tool::{Target, ToolRequest}; use crate::commands::{diagnostics, reporters::PythonDownloadReporter}; @@ -88,25 +87,47 @@ pub(crate) async fn install( let reporter = PythonDownloadReporter::single(printer); - let (python_request, explicit_python_request) = if let Some(request) = python.as_deref() { - (Some(PythonRequest::parse(request)), true) - } else { - // Discover a global Python version pin, if no request was made - ( - PythonVersionFile::discover( - // TODO(zanieb): We don't use the directory, should we expose another interface? - // Should `no_local` be implied by `None` here? - &*CWD, - &VersionFileDiscoveryOptions::default() - .with_no_config(no_config) - .with_no_local(true), + // Initialize any shared state. + let state = PlatformState::default(); + + // Parse the input requirement. + let request = ToolRequest::parse(&package, from.as_deref())?; + + let unresolved_target_requirements = match &request { + ToolRequest::Package { + target: Target::Unspecified(requirement), + .. + } => { + let source = if editable { + RequirementsSource::from_editable(requirement)? + } else { + RequirementsSource::from_package(requirement)? + }; + Some( + RequirementsSpecification::from_source(&source, &client_builder) + .await? + .requirements, ) - .await? - .and_then(PythonVersionFile::into_version), - false, - ) + } + _ => None, }; + let tool_python = ToolPython::from_request( + python.as_deref().map(PythonRequest::parse), + unresolved_target_requirements + .as_ref() + .and_then(|requirements| requirements.first()) + .map(|requirement| &requirement.requirement), + no_config, + lfs, + state.git(), + &client_builder, + &cache, + ) + .await?; + let explicit_python_request = tool_python.is_explicit(); + let python_request = tool_python.python_request; + // Pre-emptively identify a Python interpreter. We need an interpreter to resolve any unnamed // requirements, even if we end up using a different interpreter for the tool install itself. let interpreter = PythonInstallation::find_or_download( @@ -125,12 +146,6 @@ pub(crate) async fn install( .await? .into_interpreter(); - // Initialize any shared state. - let state = PlatformState::default(); - - // Parse the input requirement. - let request = ToolRequest::parse(&package, from.as_deref())?; - // If the user passed, e.g., `ruff@latest`, refresh the cache. let cache = if request.is_latest() { cache.with_refresh(Refresh::All(Timestamp::now())) @@ -145,14 +160,9 @@ pub(crate) async fn install( executable, target: Target::Unspecified(from), } => { - let source = if editable { - RequirementsSource::from_editable(from)? - } else { - RequirementsSource::from_package(from)? - }; - let requirement = RequirementsSpecification::from_source(&source, &client_builder) - .await? - .requirements; + let requirements = unresolved_target_requirements.clone().ok_or_else(|| { + anyhow::anyhow!("Expected parsed requirements for unresolved target `{from}`") + })?; // If the user provided an executable name, verify that it matches the `--from` requirement. let executable = if let Some(executable) = executable { @@ -169,7 +179,7 @@ pub(crate) async fn install( }; let requirement = resolve_names( - requirement, + requirements, &interpreter, &settings, &client_builder, diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index a81d92ec61352..d032f7374dae4 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -23,15 +23,11 @@ use uv_distribution_types::{ IndexCapabilities, IndexUrl, Name, NameRequirementSpecification, Requirement, RequirementSource, UnresolvedRequirement, UnresolvedRequirementSpecification, }; -use uv_fs::CWD; -use uv_fs::Simplified; use uv_installer::{InstallationStrategy, SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; use uv_pep508::MarkerTree; use uv_preview::Preview; -use uv_python::PythonVersionFile; -use uv_python::VersionFileDiscoveryOptions; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, @@ -41,7 +37,6 @@ use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; use uv_shell::WindowsRunnable; use uv_static::EnvVars; use uv_tool::{InstalledTools, entrypoint_paths}; -use uv_warnings::warn_user; use uv_warnings::warn_user_once; use uv_workspace::WorkspaceCache; @@ -58,9 +53,9 @@ use crate::commands::project::{ EnvironmentSpecification, PlatformState, ProjectError, resolve_names, }; use crate::commands::reporters::PythonDownloadReporter; -use crate::commands::tool::common::{matching_packages, refine_interpreter}; +use crate::commands::tool::common::{ToolPython, matching_packages, refine_interpreter}; use crate::commands::tool::{Target, ToolRequest}; -use crate::commands::{diagnostics, project::environment::CachedEnvironment}; +use crate::commands::{diagnostics, project::environment::CachedEnvironment, read_env_files}; use crate::printer::Printer; use crate::settings::ResolverInstallerSettings; use crate::settings::ResolverSettings; @@ -83,99 +78,6 @@ impl Display for ToolRunCommand { } } -/// Read dotenv files into an overlay for the spawned tool process. -/// -/// These values intentionally do not mutate uv's process environment and cannot mutate -/// the current uv process' settings. -fn read_env_files( - env_file: &[PathBuf], - no_env_file: bool, -) -> anyhow::Result> { - let mut environment = Vec::new(); - - if no_env_file { - return Ok(environment); - } - - for env_file_path in env_file.iter().rev().map(PathBuf::as_path) { - let iter = match dotenvy::from_path_iter(env_file_path) { - Err(dotenvy::Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => { - bail!( - "No environment file found at: `{}`", - env_file_path.simplified_display() - ); - } - Err(dotenvy::Error::Io(err)) => { - bail!( - "Failed to read environment file `{}`: {err}", - env_file_path.simplified_display() - ); - } - Err(dotenvy::Error::LineParse(content, position)) => { - warn_user!( - "Failed to parse environment file `{}` at position {position}: {content}", - env_file_path.simplified_display(), - ); - continue; - } - Err(err) => { - warn_user!( - "Failed to parse environment file `{}`: {err}", - env_file_path.simplified_display(), - ); - continue; - } - Ok(iter) => iter, - }; - - let mut parsed = true; - for item in iter { - match item { - Ok((key, value)) => { - if std::env::var(&key).is_err() { - environment.push((key, value)); - } - } - Err(dotenvy::Error::Io(err)) => { - bail!( - "Failed to read environment file `{}`: {err}", - env_file_path.simplified_display() - ); - } - Err(dotenvy::Error::LineParse(content, position)) => { - warn_user!( - "Failed to parse environment file `{}` at position {position}: {content}", - env_file_path.simplified_display(), - ); - parsed = false; - break; - } - Err(err) => { - warn_user!( - "Failed to parse environment file `{}`: {err}", - env_file_path.simplified_display(), - ); - parsed = false; - break; - } - } - } - - if parsed { - debug!( - "Read environment file at: `{}`", - env_file_path.simplified_display() - ); - } - } - - // `dotenvy::from_path` preserves the first loaded value, while `Command::envs` preserves the - // last value set for the child process. - environment.reverse(); - - Ok(environment) -} - /// Check if the given arguments contain a verbose flag (e.g., `--verbose`, `-v`, `-vv`, etc.) fn find_verbose_flag(args: &[std::ffi::OsString]) -> Option<&str> { args.iter().find_map(|arg| { @@ -232,7 +134,11 @@ pub(crate) async fn run( ); } - let env_file_environment = read_env_files(&env_file, no_env_file)?; + let env_file_environment = if no_env_file { + Vec::new() + } else { + read_env_files(env_file.iter())? + }; let Some(command) = command else { // When a command isn't provided, we'll show a brief help including available tools @@ -792,6 +698,17 @@ async fn get_or_create_environment( ) -> Result<(ToolRequirement, PythonEnvironment), ProjectError> { let reporter = PythonDownloadReporter::single(printer); + // Initialize any shared state. + let state = PlatformState::default(); + + let unresolved_target_requirement = match request { + ToolRequest::Package { + target: Target::Unspecified(requirement), + .. + } => Some(RequirementsSpecification::parse_package(requirement)?), + _ => None, + }; + // Determine explicit Python version requests let explicit_python_request = python.map(PythonRequest::parse); let tool_python_request = match request { @@ -799,7 +716,7 @@ async fn get_or_create_environment( ToolRequest::Package { .. } => None, }; - // Resolve Python request with version file lookup when no explicit request + // Resolve an argument-derived Python request, if any. let python_request = match (explicit_python_request, tool_python_request) { // e.g., `uvx --python 3.10 python3.12` (Some(explicit), Some(tool_request)) if tool_request != PythonRequest::Default => { @@ -814,17 +731,23 @@ async fn get_or_create_environment( // e.g, `uvx --python 3.10 ...` (Some(explicit), _) => Some(explicit), // e.g., `uvx python` or `uvx ` - (None, Some(PythonRequest::Default) | None) => PythonVersionFile::discover( - &*CWD, - &VersionFileDiscoveryOptions::default() - .with_no_config(false) - .with_no_local(true), - ) - .await? - .and_then(PythonVersionFile::into_version), + (None, Some(PythonRequest::Default) | None) => None, // e.g., `uvx python3.12` (None, Some(tool_request)) => Some(tool_request), }; + let python_request = ToolPython::from_request( + python_request, + unresolved_target_requirement + .as_ref() + .map(|requirement| &requirement.requirement), + false, + lfs, + state.git(), + client_builder, + cache, + ) + .await? + .python_request; // Discover an interpreter. let interpreter = PythonInstallation::find_or_download( @@ -843,9 +766,6 @@ async fn get_or_create_environment( .await? .into_interpreter(); - // Initialize any shared state. - let state = PlatformState::default(); - let from = match request { ToolRequest::Python { executable: request_executable, @@ -860,7 +780,11 @@ async fn get_or_create_environment( let (executable, requirement) = match target { // Ex) `ruff>=0.6.0` Target::Unspecified(requirement) => { - let spec = RequirementsSpecification::parse_package(requirement)?; + let spec = unresolved_target_requirement.clone().ok_or_else(|| { + anyhow::anyhow!( + "Expected parsed requirement for unresolved target `{requirement}`" + ) + })?; // Extract the verbatim executable name, if possible. let name = match &spec.requirement { diff --git a/crates/uv/src/commands/tool/upgrade.rs b/crates/uv/src/commands/tool/upgrade.rs index 1e987fdf21e19..8d00c018227ac 100644 --- a/crates/uv/src/commands/tool/upgrade.rs +++ b/crates/uv/src/commands/tool/upgrade.rs @@ -1,6 +1,6 @@ use anyhow::Result; use itertools::Itertools; -use owo_colors::{AnsiColors, OwoColorize}; +use owo_colors::OwoColorize; use std::collections::BTreeMap; use std::fmt::Write; use std::str::FromStr; @@ -10,6 +10,7 @@ use uv_cache::Cache; use uv_client::BaseClientBuilder; use uv_configuration::{Concurrency, Constraints, DryRun, TargetTriple}; use uv_distribution_types::{ExtraBuildRequires, Requirement, RequirementSource}; +use uv_errors::{ErrorOptions, write_error_chain_with_options}; use uv_fs::CWD; use uv_normalize::PackageName; use uv_pep440::{Operator, Version}; @@ -22,7 +23,6 @@ use uv_requirements::RequirementsSpecification; use uv_settings::{Combine, PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; use uv_tool::{InstalledTools, Tool}; use uv_types::SourceTreeEditablePolicy; -use uv_warnings::write_error_chain; use uv_workspace::WorkspaceCache; use crate::commands::pip::loggers::{ @@ -171,12 +171,10 @@ pub(crate) async fn upgrade( .sorted_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)) { trace!("Error trace: {err:?}"); - write_error_chain( + write_error_chain_with_options( err.context(format!("Failed to upgrade {}", name.green())) .as_ref(), - printer.stderr(), - "error", - AnsiColors::Red, + ErrorOptions::default().with_stream(printer.stderr()), )?; } return Ok(ExitStatus::Failure); diff --git a/crates/uv/src/commands/workspace/metadata.rs b/crates/uv/src/commands/workspace/metadata.rs index a212c70facd30..c839ad0b5946a 100644 --- a/crates/uv/src/commands/workspace/metadata.rs +++ b/crates/uv/src/commands/workspace/metadata.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use std::path::Path; -use anyhow::Result; +use anyhow::{Context, Result}; use owo_colors::OwoColorize; use uv_cache::{Cache, Refresh}; @@ -9,19 +9,23 @@ use uv_client::BaseClientBuilder; use uv_configuration::{Concurrency, DependencyGroupsWithDefaults, DryRun}; use uv_preview::{Preview, PreviewFeature}; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; -use uv_resolver::{Lock, Metadata}; -use uv_settings::PythonInstallMirrors; +use uv_resolver::Metadata; +use uv_settings::{MalwareCheckSettings, PythonInstallMirrors}; use uv_warnings::warn_user; -use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceCache}; +use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache}; use crate::commands::pip::loggers::DefaultResolveLogger; use crate::commands::project::lock::{LockMode, LockOperation}; use crate::commands::project::lock_target::LockTarget; -use crate::commands::project::{ProjectError, ProjectInterpreter, UniversalState, WorkspacePython}; +use crate::commands::project::{ + ProjectEnvironment, ProjectError, ProjectInterpreter, UniversalState, WorkspacePython, +}; use crate::commands::{ExitStatus, diagnostics}; use crate::printer::Printer; use crate::settings::{FrozenSource, LockCheck, ResolverSettings}; +use super::module_owners::collect_module_owners; + /// Display metadata about the workspace. pub(crate) async fn metadata( project_dir: &Path, @@ -29,8 +33,10 @@ pub(crate) async fn metadata( frozen: Option, dry_run: DryRun, refresh: Refresh, + sync: bool, python: Option, install_mirrors: PythonInstallMirrors, + malware_settings: MalwareCheckSettings, settings: ResolverSettings, client_builder: BaseClientBuilder<'_>, python_preference: PythonPreference, @@ -54,13 +60,14 @@ pub(crate) async fn metadata( .await?; let target = LockTarget::Workspace(virtual_project.workspace()); + // Don't enable any groups' requires-python for interpreter discovery. + let groups = DependencyGroupsWithDefaults::none(); + // Determine the lock mode. let interpreter; let mode = if let Some(frozen_source) = frozen { LockMode::Frozen(frozen_source.into()) } else { - // Don't enable any groups' requires-python for interpreter discovery - let groups = DependencyGroupsWithDefaults::none(); let workspace_python = WorkspacePython::from_request( python.as_deref().map(PythonRequest::parse), Some(virtual_project.workspace()), @@ -117,7 +124,47 @@ pub(crate) async fn metadata( ) .await { - Ok(lock) => print_lock_as_metadata(virtual_project.workspace(), &lock.into_lock(), printer), + Ok(lock) => { + let lock = lock.into_lock(); + let mut export = Metadata::from_lock(virtual_project.workspace(), &lock)?; + if sync { + let environment = ProjectEnvironment::get_or_init( + virtual_project.workspace(), + &groups, + python.as_deref().map(PythonRequest::parse), + &install_mirrors, + &client_builder, + python_preference, + python_downloads, + false, + no_config, + Some(false), + cache, + DryRun::Disabled, + printer, + preview, + ) + .await?; + let module_owners = collect_module_owners( + virtual_project.workspace(), + &lock, + &environment, + &settings, + &client_builder, + &state, + &concurrency, + cache, + workspace_cache, + preview, + &malware_settings, + ) + .await + .context("Failed to collect module owners")?; + export = export.with_module_owners(module_owners); + } + + print_metadata(&export, printer) + } Err(err @ ProjectError::LockMismatch(..)) => { writeln!(printer.stderr(), "{}", err.to_string().bold())?; Ok(ExitStatus::Failure) @@ -131,13 +178,7 @@ pub(crate) async fn metadata( } } -fn print_lock_as_metadata( - workspace: &Workspace, - lock: &Lock, - printer: Printer, -) -> Result { - let export = Metadata::from_lock(workspace, lock)?; - +fn print_metadata(export: &Metadata, printer: Printer) -> Result { writeln!(printer.stdout(), "{}", export.to_json()?)?; Ok(ExitStatus::Success) diff --git a/crates/uv/src/commands/workspace/mod.rs b/crates/uv/src/commands/workspace/mod.rs index 6ee08d3e2b0cc..e4f1ca0d77694 100644 --- a/crates/uv/src/commands/workspace/mod.rs +++ b/crates/uv/src/commands/workspace/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod dir; pub(crate) mod list; pub(crate) mod metadata; +mod module_owners; diff --git a/crates/uv/src/commands/workspace/module_owners.rs b/crates/uv/src/commands/workspace/module_owners.rs new file mode 100644 index 0000000000000..473afbef0a9fa --- /dev/null +++ b/crates/uv/src/commands/workspace/module_owners.rs @@ -0,0 +1,143 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::Result; +use uv_cache::Cache; +use uv_client::BaseClientBuilder; +use uv_configuration::{ + Concurrency, DependencyGroups, DryRun, ExtrasSpecification, InstallOptions, Reinstall, +}; +use uv_distribution_types::{Dist, Name, ResolvedDist}; +use uv_fs::PortablePathBuf; +use uv_installer::SitePackages; +use uv_normalize::{DefaultExtras, DefaultGroups, PackageName}; +use uv_preview::Preview; +use uv_pypi_types::ModuleName; +use uv_python::PythonEnvironment; +use uv_resolver::{Installable, Lock, Metadata}; +use uv_settings::MalwareCheckSettings; +use uv_workspace::{Workspace, WorkspaceCache}; + +use crate::commands::pip::loggers::DefaultInstallLogger; +use crate::commands::pip::operations::Modifications; +use crate::commands::pip::{resolution_markers, resolution_tags}; +use crate::commands::project::UniversalState; +use crate::commands::project::install_target::InstallTarget; +use crate::commands::project::sync::do_sync; +use crate::printer::Printer; +use crate::settings::{InstallerSettingsRef, ResolverSettings}; + +/// Sync all locked extras and groups as needed, then map importable modules to package IDs. +/// +/// This uses a sufficient (inexact) sync so required distributions are available to inspect +/// without removing unrelated packages from an existing environment. Only distributions in the +/// selected resolution are assigned package IDs, so those unrelated packages are not reported. +pub(crate) async fn collect_module_owners( + workspace: &Workspace, + lock: &Lock, + venv: &PythonEnvironment, + settings: &ResolverSettings, + client_builder: &BaseClientBuilder<'_>, + state: &UniversalState, + concurrency: &Concurrency, + cache: &Cache, + workspace_cache: &WorkspaceCache, + preview: Preview, + malware_settings: &MalwareCheckSettings, +) -> Result>> { + let target = InstallTarget::Workspace { workspace, lock }; + let marker_env = resolution_markers(None, None, venv.interpreter()); + let tags = resolution_tags(None, None, venv.interpreter())?; + let extras = ExtrasSpecification::from_all_extras().with_defaults(DefaultExtras::default()); + let groups = DependencyGroups::from_all_groups().with_defaults(DefaultGroups::default()); + + let resolution = target.to_resolution( + &marker_env, + &tags, + &extras, + &groups, + &settings.build_options, + &InstallOptions::default(), + )?; + if resolution.is_empty() { + return Ok(BTreeMap::new()); + } + + let workspace_root = PortablePathBuf::from(workspace.install_path().as_path()); + let mut package_ids = BTreeMap::::new(); + for dist in resolution.distributions().filter(|dist| !is_virtual(dist)) { + package_ids.insert( + dist.name().clone(), + Metadata::package_node_id(&workspace_root, dist)?, + ); + } + + let reinstall = Reinstall::None; + let installer_settings = InstallerSettingsRef { + index_locations: &settings.index_locations, + index_strategy: settings.index_strategy, + keyring_provider: settings.keyring_provider, + dependency_metadata: &settings.dependency_metadata, + config_setting: &settings.config_setting, + config_settings_package: &settings.config_settings_package, + build_isolation: &settings.build_isolation, + extra_build_dependencies: &settings.extra_build_dependencies, + extra_build_variables: &settings.extra_build_variables, + exclude_newer: &settings.exclude_newer, + link_mode: settings.link_mode, + compile_bytecode: false, + reinstall: &reinstall, + build_options: &settings.build_options, + sources: settings.sources.clone(), + }; + + do_sync( + target, + venv, + &extras, + &groups, + None, + InstallOptions::default(), + Modifications::Sufficient, + None, + installer_settings, + client_builder, + &state.fork(), + Box::new(DefaultInstallLogger), + false, + concurrency, + cache, + workspace_cache, + DryRun::Disabled, + Printer::Silent, + preview, + malware_settings, + ) + .await?; + + let mut owners = BTreeMap::>::new(); + for dist in SitePackages::from_environment(venv)?.iter() { + let Some(package_id) = package_ids.get(dist.name()) else { + continue; + }; + // TODO: Editable installs often only record a `.pth` file; we'll + // need to handle them specially. + for module in dist.read_modules(venv.interpreter().extension_suffixes())? { + owners.entry(module).or_default().insert(package_id.clone()); + } + } + + Ok(owners + .into_iter() + .map(|(module, owners)| (module, owners.into_iter().collect())) + .collect()) +} + +fn is_virtual(dist: &ResolvedDist) -> bool { + let ResolvedDist::Installable { dist, .. } = dist else { + return false; + }; + let Dist::Source(source) = dist.as_ref() else { + return false; + }; + source.is_virtual() +} diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 8eb95960a22fd..809473186d74a 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -11,7 +11,6 @@ use std::process::ExitCode; use std::str::FromStr; use std::sync::atomic::Ordering; -use anstream::eprintln; use anyhow::{Result, anyhow, bail}; use clap::error::{ContextKind, ContextValue}; use clap::{CommandFactory, Parser}; @@ -1284,6 +1283,13 @@ async fn run(cli: Cli) -> Result { args.allow_existing, args.clear, args.no_clear, + if args.force { + uv_virtualenv::ClearNonVirtualenv::Allow + } else if globals.preview.is_enabled(PreviewFeature::VenvSafeClear) { + uv_virtualenv::ClearNonVirtualenv::Error + } else { + uv_virtualenv::ClearNonVirtualenv::Warn + }, ); Box::pin(commands::venv( @@ -1954,8 +1960,10 @@ async fn run(cli: Cli) -> Result { args.frozen, args.dry_run, args.refresh, + args.sync, args.python, args.install_mirrors, + args.malware_settings, args.settings, client_builder.subcommand(vec!["workspace".to_owned(), "metadata".to_owned()]), globals.python_preference, @@ -2839,21 +2847,12 @@ where Err(err) => { trace!("Error trace: {err:?}"); - // Collect hints before rendering the error chain. let hints = commands::diagnostics::hints_for_error(&err); - - let mut causes = err.chain(); - eprintln!( - "{}: {}", - "error".red().bold(), - causes.next().unwrap().to_string().trim() - ); - for err in causes { - eprintln!(" {}: {}", "Caused by".red().bold(), err.to_string().trim()); - } - - // Render hints after the error chain. - anstream::eprint!("{hints}"); + uv_errors::write_error_chain_with_options( + err.as_ref(), + uv_errors::ErrorOptions::default().with_hints(hints), + ) + .expect("writing to stderr should not fail"); ExitStatus::Error.into() } diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index e00ca9b80771f..bc041a3a8bff9 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -635,6 +635,7 @@ impl RunSettings { only_dev, editable, no_editable, + no_editable_package, inexact, exact, script: _, @@ -725,7 +726,10 @@ impl RunSettings { only_group, all_groups, ), - editable: flag(editable.into(), no_editable.into(), "editable").map(EditableMode::from), + editable: EditableMode::from_args( + flag(editable.into(), no_editable.into(), "editable"), + no_editable_package, + ), modifications: if flag(exact, inexact, "inexact").unwrap_or(false) { Modifications::Exact } else { @@ -1718,6 +1722,7 @@ impl SyncSettings { all_groups, editable, no_editable, + no_editable_package, inexact, exact, no_install_project, @@ -1819,7 +1824,10 @@ impl SyncSettings { only_group, all_groups, ), - editable: flag(editable.into(), no_editable.into(), "editable").map(EditableMode::from), + editable: EditableMode::from_args( + flag(editable.into(), no_editable.into(), "editable"), + no_editable_package, + ), install_options: InstallOptions::new( no_install_project, only_install_project, @@ -1923,10 +1931,12 @@ pub(crate) struct MetadataSettings { pub(crate) lock_check: LockCheck, pub(crate) frozen: Option, pub(crate) dry_run: DryRun, + pub(crate) sync: bool, pub(crate) python: Option, pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) refresh: Refresh, pub(crate) settings: ResolverSettings, + pub(crate) malware_settings: MalwareCheckSettings, } impl MetadataSettings { @@ -1943,6 +1953,7 @@ impl MetadataSettings { resolver, build, refresh, + sync, python, } = *args; @@ -1958,10 +1969,13 @@ impl MetadataSettings { // Check for conflicts between locked and frozen. check_conflicts(locked, frozen); + let malware_settings = MalwareCheckSettings::from(&environment); + Self { lock_check: resolve_lock_check(locked), frozen: resolve_frozen(frozen), dry_run: DryRun::from_args(dry_run), + sync, python: python.and_then(Maybe::into_option), refresh: Refresh::from(refresh), settings: ResolverSettings::combine( @@ -1972,6 +1986,7 @@ impl MetadataSettings { install_mirrors: environment .install_mirrors .combine(filesystem_install_mirrors), + malware_settings, } } } @@ -1989,7 +2004,7 @@ pub(crate) struct AddSettings { pub(crate) constraints: Vec, pub(crate) marker: Option, pub(crate) dependency_type: DependencyType, - pub(crate) editable: Option, + pub(crate) editable: Option, pub(crate) extras: Vec, pub(crate) raw: bool, pub(crate) bounds: Option, @@ -2033,6 +2048,7 @@ impl AddSettings { group, editable, no_editable, + no_editable_package, extra, raw, bounds, @@ -2196,7 +2212,10 @@ impl AddSettings { only_install_local, no_install_package, only_install_package, - editable: flag(editable.into(), no_editable.into(), "editable"), + editable: EditableMode::from_args( + flag(editable.into(), no_editable.into(), "editable"), + no_editable_package, + ), extras: extra.unwrap_or_default(), refresh: Refresh::from(refresh), indexes, @@ -2574,6 +2593,7 @@ impl ExportSettings { no_header, editable, no_editable, + no_editable_package, hashes, no_hashes, output_file, @@ -2650,7 +2670,10 @@ impl ExportSettings { only_group, all_groups, ), - editable: flag(editable.into(), no_editable.into(), "editable").map(EditableMode::from), + editable: EditableMode::from_args( + flag(editable.into(), no_editable.into(), "editable"), + no_editable_package, + ), hashes: flag(hashes, no_hashes, "hashes").unwrap_or(true), install_options: InstallOptions::new( no_emit_project, @@ -3198,6 +3221,7 @@ impl PipInstallSettings { requirements, editable, no_editable, + no_editable_package, constraints, overrides, excludes, @@ -3317,11 +3341,14 @@ impl PipInstallSettings { } else { Modifications::Sufficient }, - editable: if no_editable || environment.no_editable.value == Some(true) { - Some(EditableMode::NonEditable) - } else { - None - }, + editable: EditableMode::from_args( + if no_editable || environment.no_editable.value == Some(true) { + Some(false) + } else { + None + }, + no_editable_package, + ), refresh: Refresh::from(refresh), settings: PipSettings::combine( PipOptions { @@ -3770,6 +3797,7 @@ pub(crate) struct VenvSettings { pub(crate) seed: bool, pub(crate) allow_existing: bool, pub(crate) clear: bool, + pub(crate) force: bool, pub(crate) no_clear: bool, pub(crate) path: Option, pub(crate) prompt: Option, @@ -3795,6 +3823,7 @@ impl VenvSettings { seed, allow_existing, clear, + force, no_clear, path, prompt, @@ -3835,6 +3864,7 @@ impl VenvSettings { seed, allow_existing, clear: clear.into(), + force, no_clear: no_clear.into(), path, prompt, diff --git a/crates/uv/tests/it/build_backend.rs b/crates/uv/tests/it/build_backend.rs index 5ea5aa105a7d6..35fe8e75abba2 100644 --- a/crates/uv/tests/it/build_backend.rs +++ b/crates/uv/tests/it/build_backend.rs @@ -1388,6 +1388,8 @@ fn build_with_all_metadata() -> Result<()> { "download_url": null, "dynamic": [], "home_page": null, + "import_names": [], + "import_namespaces": [], "keywords": [ "example", "test", diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 235f423b9cae3..1df5610681d34 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -3967,7 +3967,7 @@ fn add_update_git_reference_project() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) "); @@ -3980,7 +3980,7 @@ fn add_update_git_reference_project() -> Result<()> { Resolved 2 packages in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) "); @@ -3994,7 +3994,7 @@ fn add_update_git_reference_project() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@2005223fcad0e2c06daf2e14b93b790604868e1e) "); @@ -4690,6 +4690,65 @@ fn add_error() -> Result<()> { Ok(()) } +/// Suggest avoiding dependencies for modules in the Python standard library. +#[test] +fn add_standard_library_error() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("pickle"), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because pickle was not found in the package registry and your project depends on pickle, we can conclude that your project's requirements are unsatisfiable. + + hint: The module `pickle` is included in the Python standard library and usually should not be added as a dependency + hint: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing + "); + + Ok(()) +} + +/// Avoid suggesting a standard-library alternative for unrelated resolution failures. +#[test] +fn add_standard_library_unrelated_resolution_error() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("typing").arg("xyz"), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because there are no versions of xyz and your project depends on xyz, we can conclude that your project's requirements are unsatisfiable. + + hint: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing + "); + + Ok(()) +} + /// Emit dedicated error message when adding Conda `environment.yml` #[test] fn add_environment_yml_error() -> Result<()> { @@ -13576,13 +13635,91 @@ async fn add_empty_ignore_error_codes() -> Result<()> { × No solution found when resolving dependencies: ╰─▶ Because anyio was not found in the package registry and your project depends on anyio, we can conclude that your project's requirements are unsatisfiable. - hint: An index URL (http://[LOCALHOST]/) returned a 403 Forbidden error. This could indicate lack of valid authentication credentials, or the package may not exist on this index. + hint: An index (http://[LOCALHOST]/) returned a 403 Forbidden error. Check that the index URL is correct and the credentials are valid. hint: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing " ); Ok(()) } +/// If a package was available from an index that returned a 403, uv should suggest that the index +/// might use 403s for packages that are not found. +#[tokio::test] +async fn lock_forbidden_index_with_available_package() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/anyio/")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + r#" + { + "name": "anyio", + "files": [{ + "filename": "anyio-4.3.0-py3-none-any.whl", + "url": "/anyio-4.3.0-py3-none-any.whl", + "hashes": { + "sha256": "048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8" + }, + "core-metadata": true, + "requires-python": ">=3.8", + "upload-time": "2024-02-19T08:36:26Z" + }] + } + "#, + "application/vnd.pypi.simple.v1+json", + )) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/anyio-4.3.0-py3-none-any.whl.metadata")) + .respond_with(ResponseTemplate::new(200).set_body_string(indoc! {" + Metadata-Version: 2.3 + Name: anyio + Version: 4.3.0 + Requires-Dist: idna>=2.8 + "})) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/idna/")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(&formatdoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = ["anyio"] + + [[tool.uv.index]] + name = "my-index" + url = "{server_url}" + ignore-error-codes = [] + default = true + "#, + server_url = server.uri(), + })?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because idna was not found in the package registry and anyio==4.3.0 depends on idna>=2.8, we can conclude that anyio==4.3.0 cannot be used. + And because only anyio==4.3.0 is available and your project depends on anyio, we can conclude that your project's requirements are unsatisfiable. + + hint: An index (http://[LOCALHOST]/) returned a 403 Forbidden error, but uv received a successful response from another request to the index. If the failing package is not present on this index, consider adding `ignore-error-codes = [403]` to the index's `[[tool.uv.index]]` entry to continue searching across indexes. + "); + Ok(()) +} + /// uv should not report a credential error on a missing package for pytorch since /// pytorch returns 403s to indicate not found. #[test] diff --git a/crates/uv/tests/it/export.rs b/crates/uv/tests/it/export.rs index 208598e350300..6548dd9a0b5b1 100644 --- a/crates/uv/tests/it/export.rs +++ b/crates/uv/tests/it/export.rs @@ -2583,6 +2583,36 @@ fn requirements_txt_no_editable() -> Result<()> { Resolved 6 packages in [TIME] "); + uv_snapshot!(context.filters(), context.export().arg("--no-editable-package").arg("child"), @r" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv export --cache-dir [CACHE_DIR] --no-editable-package child + -e . + ./child + # via project + anyio==3.7.0 \ + --hash=sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce \ + --hash=sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0 + # via project + idna==3.6 \ + --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ + --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f + # via anyio + iniconfig==2.0.0 \ + --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ + --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 + # via child + sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anyio + + ----- stderr ----- + Resolved 6 packages in [TIME] + "); + Ok(()) } diff --git a/crates/uv/tests/it/help.rs b/crates/uv/tests/it/help.rs index 2a6622746c889..0f1248ebec8dc 100644 --- a/crates/uv/tests/it/help.rs +++ b/crates/uv/tests/it/help.rs @@ -34,6 +34,7 @@ fn help() { venv Create a virtual environment build Build Python packages into source distributions and wheels publish Upload distributions to an index + workspace Inspect uv workspaces cache Manage uv's cache self Manage the uv executable generate-shell-completion Generate shell completion @@ -98,27 +99,28 @@ fn help_flag() { Usage: uv [OPTIONS] Commands: - auth Manage authentication - run Run a command or script - init Create a new project - add Add dependencies to the project - remove Remove dependencies from the project - version Read or update the project's version - sync Update the project's environment - lock Update the project's lockfile - export Export the project's lockfile to an alternate format - tree Display the project's dependency tree - format Format Python code in the project - audit Audit the project's dependencies - tool Run and install commands provided by Python packages - python Manage Python versions and installations - pip Manage Python packages with a pip-compatible interface - venv Create a virtual environment - build Build Python packages into source distributions and wheels - publish Upload distributions to an index - cache Manage uv's cache - self Manage the uv executable - help Display documentation for a command + auth Manage authentication + run Run a command or script + init Create a new project + add Add dependencies to the project + remove Remove dependencies from the project + version Read or update the project's version + sync Update the project's environment + lock Update the project's lockfile + export Export the project's lockfile to an alternate format + tree Display the project's dependency tree + format Format Python code in the project + audit Audit the project's dependencies + tool Run and install commands provided by Python packages + python Manage Python versions and installations + pip Manage Python packages with a pip-compatible interface + venv Create a virtual environment + build Build Python packages into source distributions and wheels + publish Upload distributions to an index + workspace Inspect uv workspaces + cache Manage uv's cache + self Manage the uv executable + help Display documentation for a command Cache options: -n, --no-cache Avoid reading from or writing to the cache, instead using a temporary @@ -179,27 +181,28 @@ fn help_short_flag() { Usage: uv [OPTIONS] Commands: - auth Manage authentication - run Run a command or script - init Create a new project - add Add dependencies to the project - remove Remove dependencies from the project - version Read or update the project's version - sync Update the project's environment - lock Update the project's lockfile - export Export the project's lockfile to an alternate format - tree Display the project's dependency tree - format Format Python code in the project - audit Audit the project's dependencies - tool Run and install commands provided by Python packages - python Manage Python versions and installations - pip Manage Python packages with a pip-compatible interface - venv Create a virtual environment - build Build Python packages into source distributions and wheels - publish Upload distributions to an index - cache Manage uv's cache - self Manage the uv executable - help Display documentation for a command + auth Manage authentication + run Run a command or script + init Create a new project + add Add dependencies to the project + remove Remove dependencies from the project + version Read or update the project's version + sync Update the project's environment + lock Update the project's lockfile + export Export the project's lockfile to an alternate format + tree Display the project's dependency tree + format Format Python code in the project + audit Audit the project's dependencies + tool Run and install commands provided by Python packages + python Manage Python versions and installations + pip Manage Python packages with a pip-compatible interface + venv Create a virtual environment + build Build Python packages into source distributions and wheels + publish Upload distributions to an index + workspace Inspect uv workspaces + cache Manage uv's cache + self Manage the uv executable + help Display documentation for a command Cache options: -n, --no-cache Avoid reading from or writing to the cache, instead using a temporary @@ -247,6 +250,67 @@ fn help_short_flag() { "#); } +#[test] +fn help_flag_workspace() { + let context = uv_test::test_context_with_versions!(&[]); + + uv_snapshot!(context.filters(), context.command().arg("workspace").arg("--help"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + Inspect uv workspaces + + Usage: uv workspace [OPTIONS] + + Commands: + metadata View metadata about the current workspace + dir Display the path of a workspace member + list List the members of a workspace + + Cache options: + -n, --no-cache Avoid reading from or writing to the cache, instead using a temporary + directory for the duration of the operation [env: UV_NO_CACHE=] + --cache-dir [CACHE_DIR] Path to the cache directory [env: UV_CACHE_DIR=] + + Python options: + --managed-python Require use of uv-managed Python versions [env: UV_MANAGED_PYTHON=] + --no-managed-python Disable use of uv-managed Python versions [env: UV_NO_MANAGED_PYTHON=] + --no-python-downloads Disable automatic downloads of Python. [env: + "UV_PYTHON_DOWNLOADS=never"] + + Global options: + -q, --quiet... + Use quiet output + -v, --verbose... + Use verbose output + --color + Control the use of color in output [possible values: auto, always, never] + --system-certs + Whether to load TLS certificates from the platform's native certificate store [env: + UV_SYSTEM_CERTS=] + --offline + Disable network access [env: UV_OFFLINE=] + --allow-insecure-host + Allow insecure connections to a host [env: UV_INSECURE_HOST=] + --no-progress + Hide all progress outputs [env: UV_NO_PROGRESS=] + --directory + Change to the given directory prior to running the command [env: UV_WORKING_DIR=] + --project + Discover a project in the given directory [env: UV_PROJECT=] + --config-file + The path to a `uv.toml` file to use for configuration [env: UV_CONFIG_FILE=] + --no-config + Avoid discovering configuration files (`pyproject.toml`, `uv.toml`) [env: UV_NO_CONFIG=] + -h, --help + Display the concise help for this command + + Use `uv help workspace` for more details. + + ----- stderr ----- + "#); +} + #[test] fn help_subcommand() { let context = uv_test::test_context_with_versions!(&[]); @@ -918,6 +982,7 @@ fn help_unknown_subcommand() { venv build publish + workspace cache self generate-shell-completion @@ -948,6 +1013,7 @@ fn help_unknown_subcommand() { venv build publish + workspace cache self generate-shell-completion @@ -1007,6 +1073,7 @@ fn help_with_global_option() { venv Create a virtual environment build Build Python packages into source distributions and wheels publish Upload distributions to an index + workspace Inspect uv workspaces cache Manage uv's cache self Manage the uv executable generate-shell-completion Generate shell completion @@ -1130,6 +1197,7 @@ fn help_with_no_pager() { venv Create a virtual environment build Build Python packages into source distributions and wheels publish Upload distributions to an index + workspace Inspect uv workspaces cache Manage uv's cache self Manage the uv executable generate-shell-completion Generate shell completion diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 328d1518626a8..efdfa184744a6 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -1064,6 +1064,46 @@ fn lock_sdist_git_archive() -> Result<()> { Ok(()) } +/// Reject a Git source archive when Git LFS was requested but could not be initialized. +#[test] +#[cfg(feature = "test-git")] +fn lock_sdist_git_archive_missing_lfs() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv.sources] + iniconfig = { git = "https://github.com/astral-sh/archive-in-git-test", path = "archives/iniconfig-2.0.0.tar.gz", lfs = true } + "#, + )?; + + uv_snapshot!( + context.filters(), + context + .lock() + .env(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED, "1"), + @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download and build `iniconfig @ git+https://github.com/astral-sh/archive-in-git-test#lfs=true&path=archives/iniconfig-2.0.0.tar.gz` + ├─▶ The source distribution `git+https://github.com/astral-sh/archive-in-git-test#lfs=true&path=archives/iniconfig-2.0.0.tar.gz` is missing Git LFS artifacts. + ╰─▶ Git LFS extension not found. Ensure that Git LFS is installed and available. + "### + ); + + Ok(()) +} + /// Lock a Git requirement that points to a pre-built wheel within a repository. #[test] #[cfg(feature = "test-git")] @@ -1185,6 +1225,46 @@ fn lock_wheel_git_archive() -> Result<()> { Ok(()) } +/// Reject a Git wheel archive when Git LFS was requested but could not be initialized. +#[test] +#[cfg(feature = "test-git")] +fn lock_wheel_git_archive_missing_lfs() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + + [tool.uv.sources] + iniconfig = { git = "https://github.com/astral-sh/archive-in-git-test", path = "archives/iniconfig-2.0.0-py3-none-any.whl", lfs = true } + "#, + )?; + + uv_snapshot!( + context.filters(), + context + .lock() + .env(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED, "1"), + @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download `iniconfig @ git+https://github.com/astral-sh/archive-in-git-test#lfs=true&path=archives/iniconfig-2.0.0-py3-none-any.whl` + ├─▶ The wheel `git+https://github.com/astral-sh/archive-in-git-test#lfs=true&path=archives/iniconfig-2.0.0-py3-none-any.whl` is missing Git LFS artifacts. + ╰─▶ Git LFS extension not found. Ensure that Git LFS is installed and available. + "### + ); + + Ok(()) +} + /// Lock a requirement from a direct URL to a wheel. #[test] fn lock_wheel_url() -> Result<()> { @@ -1301,15 +1381,15 @@ fn lock_wheel_url() -> Result<()> { Resolved 4 packages in [TIME] "); - // Re-run with `--offline`. This should fail: we need network access to resolve mutable metadata. - uv_snapshot!(context.filters(), context.lock().arg("--locked").arg("--offline").arg("--no-cache"), @" - success: false - exit_code: 2 + // Re-run with `--check --offline`. We cannot refresh direct URL metadata without network + // access, so preserve the metadata in the already-correct lockfile. + uv_snapshot!(context.filters(), context.lock().arg("--check").arg("--offline").arg("--no-cache"), @" + success: true + exit_code: 0 ----- stdout ----- ----- stderr ----- - error: Failed to generate package metadata for `anyio==4.3.0 @ direct+https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl` - Caused by: Network connectivity is disabled, but the requested data wasn't found in the cache for: `https://files.pythonhosted.org/packages/14/fd/2f20c40b45e4fb4324834aea24bd4afdf1143390242c0b33774da0e2e34f/anyio-4.3.0-py3-none-any.whl` + Resolved 4 packages in [TIME] "); // Install from the lockfile. @@ -1453,6 +1533,17 @@ fn lock_sdist_url() -> Result<()> { Resolved 4 packages in [TIME] "); + // Re-run with `--check --offline`. We cannot refresh direct URL metadata without network + // access, so preserve the metadata in the already-correct lockfile. + uv_snapshot!(context.filters(), context.lock().arg("--check").arg("--offline").arg("--no-cache"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + "); + // Install from the lockfile. uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @" success: true @@ -13735,6 +13826,75 @@ fn lock_sources_url() -> Result<()> { Ok(()) } +/// Skip direct URL freshness checks while offline without skipping mutable transitive sources. +#[test] +fn lock_sources_url_offline_validates_transitive_source_tree() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // The remote `workspace` archive depends on `anyio`. Override that transitive dependency to + // a local source tree so it remains refreshable while the direct URL parent is skipped + // offline; changing its version below proves that we still recurse into the parent's locked + // dependencies. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["workspace @ https://github.com/user-attachments/files/16592193/workspace.zip"] + + [tool.uv] + override-dependencies = ["anyio"] + + [tool.uv.sources] + anyio = { path = "anyio" } + "#, + )?; + + let transitive = context.temp_dir.child("anyio"); + fs_err::create_dir_all(&transitive)?; + let transitive_pyproject_toml = transitive.child("pyproject.toml"); + transitive_pyproject_toml.write_str( + r#" + [project] + name = "anyio" + version = "0.1.0" + requires-python = ">=3.12" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + transitive_pyproject_toml.write_str( + r#" + [project] + name = "anyio" + version = "0.2.0" + requires-python = ">=3.12" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().arg("--check").arg("--offline"), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + The lockfile at `uv.lock` needs to be updated, but `--check` was provided. To update the lockfile, run `uv lock`. + "); + + Ok(()) +} + /// Lock a local archive requirement that uses `tool.uv.sources` internally (specifically, the root /// package `workspace` depends on `anyio`, and declares a source pointing to a local stub of /// `anyio`). diff --git a/crates/uv/tests/it/lock_conflict.rs b/crates/uv/tests/it/lock_conflict.rs index d2f02118459b4..e3e5744b50ac7 100644 --- a/crates/uv/tests/it/lock_conflict.rs +++ b/crates/uv/tests/it/lock_conflict.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use anyhow::Result; use assert_fs::prelude::*; use insta::assert_snapshot; @@ -16568,3 +16570,103 @@ fn project_level_conflict_with_group() -> Result<()> { Ok(()) } + +/// See: +#[test] +fn many_conflicts_with_requested_dependency_extra() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let root_pyproject_toml = context.temp_dir.child("pyproject.toml"); + // 29 conflicts results in a reasonably short run-time after fixing, and a very long runtime + // without the fix. This is to attempt to ensure that this test won't fail sporadically on a + // slow machine, or succeed (while broken) on a super fast machine. + root_pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = "==3.12.*" + dependencies = [] + + [project.optional-dependencies] + x00 = ["branch-package"] + + [tool.uv.sources] + branch-package = { path = "branch-package" } + + [tool.uv] + conflicts = [[ + { extra = "x00" }, + { extra = "x01" }, + { extra = "x02" }, + { extra = "x03" }, + { extra = "x04" }, + { extra = "x05" }, + { extra = "x06" }, + { extra = "x07" }, + { extra = "x08" }, + { extra = "x09" }, + { extra = "x10" }, + { extra = "x11" }, + { extra = "x12" }, + { extra = "x13" }, + { extra = "x14" }, + { extra = "x15" }, + { extra = "x16" }, + { extra = "x17" }, + { extra = "x18" }, + { extra = "x19" }, + { extra = "x20" }, + { extra = "x21" }, + { extra = "x22" }, + { extra = "x23" }, + { extra = "x24" }, + { extra = "x25" }, + { extra = "x26" }, + { extra = "x27" }, + { extra = "x28" }, + ]] + "#, + )?; + + let branch_pyproject_toml = context + .temp_dir + .child("branch-package") + .child("pyproject.toml"); + branch_pyproject_toml.write_str( + r#" + [project] + name = "branch-package" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["leaf-package[easy]"] + + [tool.uv.sources] + leaf-package = { path = "../leaf-package" } + "#, + )?; + + let leaf_pyproject_toml = context + .temp_dir + .child("leaf-package") + .child("pyproject.toml"); + leaf_pyproject_toml.write_str( + r#" + [project] + name = "leaf-package" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [project.optional-dependencies] + easy = [] + "#, + )?; + + assert_cmd::Command::from_std(context.lock()) + .timeout(Duration::from_mins(1)) + .assert() + .success(); + + Ok(()) +} diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index c381c74cc2364..357352f68c12a 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -1651,6 +1651,36 @@ fn install_no_editable() { assert!(!path.exists()); } +#[test] +fn install_no_editable_package() { + let context = uv_test::test_context!("3.12"); + let executable_file = context.workspace_root.join("test/packages/executable_file"); + let black = context.workspace_root.join("test/packages/black_editable"); + + uv_snapshot!(context.filters(), context.pip_install() + .arg("-e") + .arg(&executable_file) + .arg("-e") + .arg(&black) + .arg("--no-editable-package") + .arg("executable-file"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 2 packages in [TIME] + Installed 2 packages in [TIME] + + black==0.1.0 (from file://[WORKSPACE]/test/packages/black_editable) + + executable-file==1.0.0 (from file://[WORKSPACE]/test/packages/executable_file) + " + ); + + assert!(context.site_packages().join("black.pth").is_file()); + assert!(!context.site_packages().join("executable_file.pth").exists()); +} + #[test] fn install_no_editable_requirements_txt() -> Result<()> { let context = uv_test::test_context!("3.12"); @@ -7483,6 +7513,51 @@ fn find_links() { ); } +/// Install from a `--find-links` HTML page with uppercase tag and attribute names. +#[tokio::test] +async fn find_links_uppercase_html() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let server = MockServer::start().await; + let wheel_filename = "tqdm-1000.0.0-py3-none-any.whl"; + let wheel_url = Url::from_file_path( + context + .workspace_root + .join("test/links") + .join(wheel_filename), + ) + .map_err(|()| anyhow!("Failed to convert wheel path to URL"))?; + + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + format!( + "{wheel_filename}" + ), + "text/html", + )) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context.pip_install() + .arg("tqdm") + .arg("--no-index") + .arg("--find-links") + .arg(server.uri()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + tqdm==1000.0.0 + " + ); + + Ok(()) +} + /// Sync using `--find-links` with a local directory, with wheels disabled. #[test] fn find_links_no_binary() { @@ -10573,11 +10648,11 @@ fn cyclic_build_dependency() { #[test] #[cfg(feature = "test-git")] -fn direct_url_json_git_default() -> Result<()> { +fn direct_url_json_git_preserves_repository_url() -> Result<()> { let context = uv_test::test_context!("3.12"); let requirements_txt = context.temp_dir.child("requirements.txt"); requirements_txt.write_str( - "uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage", + "uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage.git", )?; uv_snapshot!(context.pip_install() @@ -10592,7 +10667,7 @@ fn direct_url_json_git_default() -> Result<()> { Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] - + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) " ); @@ -10604,7 +10679,7 @@ fn direct_url_json_git_default() -> Result<()> { direct_url.assert(predicates::path::is_file()); let direct_url_content = fs_err::read_to_string(direct_url.path())?; - insta::assert_snapshot!(direct_url_content, @r#"{"url":"https://github.com/astral-test/uv-public-pypackage","vcs_info":{"vcs":"git","commit_id":"b270df1a2fb5d012294e9aaf05e7e0bab1e6a389"}}"#); + insta::assert_snapshot!(direct_url_content, @r#"{"url":"https://github.com/astral-test/uv-public-pypackage.git","vcs_info":{"vcs":"git","commit_id":"b270df1a2fb5d012294e9aaf05e7e0bab1e6a389"}}"#); Ok(()) } @@ -13419,6 +13494,135 @@ fn reject_normalized_reserved_gui_wheel_entrypoint_name() -> Result<()> { Ok(()) } +#[test] +fn reject_free_threaded_python_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = + repacked_wheel_with_entrypoint(&context, "console_scripts", "python3.13t")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `python3.13t`, got: `python3.13t` + " + ); + + Ok(()) +} + +#[test] +fn reject_windowed_free_threaded_python_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = + repacked_wheel_with_entrypoint(&context, "console_scripts", "pythonw3.13t")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `pythonw3.13t`, got: `pythonw3.13t` + " + ); + + Ok(()) +} + +#[test] +fn reject_windows_rewritten_python_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = repacked_wheel_with_entrypoint(&context, "console_scripts", "python.py")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `python`, got: `python.py` + " + ); + + Ok(()) +} + +#[test] +fn reject_windows_rewritten_free_threaded_python_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = + repacked_wheel_with_entrypoint(&context, "console_scripts", "pythonw3.13t.py")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `pythonw3.13t`, got: `pythonw3.13t.py` + " + ); + + Ok(()) +} + +#[test] +fn reject_pypy_major_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = repacked_wheel_with_entrypoint(&context, "console_scripts", "pypy3")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `pypy3`, got: `pypy3` + " + ); + + Ok(()) +} + +#[test] +fn reject_windows_rewritten_pypy_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = repacked_wheel_with_entrypoint(&context, "console_scripts", "pypy.py")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `pypy`, got: `pypy.py` + " + ); + + Ok(()) +} + #[test] fn warn_normalized_activation_wheel_entrypoint_name() -> Result<()> { let context = uv_test::test_context!("3.12"); diff --git a/crates/uv/tests/it/publish.rs b/crates/uv/tests/it/publish.rs index 60a3dcf06e2fc..5a04643b26277 100644 --- a/crates/uv/tests/it/publish.rs +++ b/crates/uv/tests/it/publish.rs @@ -515,6 +515,103 @@ async fn read_index_credential_env_vars_for_check_url() { ); } +#[tokio::test] +async fn check_url_missing_package_ignores_content_type() { + let context = uv_test::test_context!("3.12"); + + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/simple/ok/")) + .respond_with( + ResponseTemplate::new(404).set_body_raw("Not found", "text/plain; charset=UTF-8"), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/upload")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + uv_snapshot!(context.filters(), context.publish() + .arg("-u") + .arg("dummy") + .arg("-p") + .arg("dummy") + .arg("--check-url") + .arg(format!("{}/simple/", server.uri())) + .arg("--publish-url") + .arg(format!("{}/upload", server.uri())) + .arg(dummy_wheel()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Publishing 1 file to http://[LOCALHOST]/upload + Hashing ok-1.0.0-py3-none-any.whl ([SIZE]) + Uploading ok-1.0.0-py3-none-any.whl ([SIZE]) + " + ); +} + +#[tokio::test] +async fn check_url_missing_package_follows_redirect() { + let context = uv_test::test_context!("3.12"); + + let gitlab_server = MockServer::start().await; + let pypi_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/simple/ok/")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("Location", format!("{}/simple/ok/", pypi_server.uri())) + .set_body_raw("Redirecting", "text/plain"), + ) + .expect(1) + .mount(&gitlab_server) + .await; + Mock::given(method("GET")) + .and(path("/simple/ok/")) + .respond_with( + ResponseTemplate::new(404).set_body_raw("Not found", "text/plain; charset=UTF-8"), + ) + .expect(1) + .mount(&pypi_server) + .await; + Mock::given(method("POST")) + .and(path("/upload")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&gitlab_server) + .await; + + uv_snapshot!(context.filters(), context.publish() + .arg("-u") + .arg("dummy") + .arg("-p") + .arg("dummy") + .arg("--check-url") + .arg(format!("{}/simple/", gitlab_server.uri())) + .arg("--publish-url") + .arg(format!("{}/upload", gitlab_server.uri())) + .arg(dummy_wheel()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Publishing 1 file to http://[LOCALHOST]/upload + Hashing ok-1.0.0-py3-none-any.whl ([SIZE]) + Uploading ok-1.0.0-py3-none-any.whl ([SIZE]) + " + ); +} + /// Native GitLab CI trusted publishing using `PYPI_ID_TOKEN` #[tokio::test] async fn gitlab_trusted_publishing_pypi_id_token() { diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 2802b838037cf..5944bed781f5c 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -507,6 +507,68 @@ fn run_pep723_script() -> Result<()> { error: An opening tag (`# /// script`) was found without a closing tag (`# ///`). Ensure that every line between the opening and closing tags (including empty lines) starts with a leading `#`. "); + // Regression test for: + let test_script = context.temp_dir.child("main.py"); + test_script.write_str(indoc! { r#" + # /// script + # dependencies = [] + # /// + + print("Hello, world!") + + # /// script + # dependencies = [] + # /// + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg("--no-project").arg("main.py"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The script contains multiple PEP 723 metadata blocks + "); + + Ok(()) +} + +/// A PEP 723 script with a long file name should still succeed at creating a script environment on +/// Windows. +#[test] +fn run_pep723_script_long_filename() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + // The cache environment entry path, which is derived from the script's name, would exceed many + // common path component length limits if it was not truncated first. + let script_name = format!("{}.py", "a".repeat(240)); + let test_script = context.temp_dir.child(&script_name); + test_script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "iniconfig", + # ] + # /// + + print("Hello, world!") + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg(&script_name), @" + success: true + exit_code: 0 + ----- stdout ----- + Hello, world! + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); + Ok(()) } @@ -3162,6 +3224,20 @@ fn run_editable() -> Result<()> { + iniconfig==2.0.0 "); + uv_snapshot!(context.filters(), context.run().arg("--no-editable-package").arg("foo").arg("main.py"), @" + success: true + exit_code: 0 + ----- stdout ----- + Hello, world! + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ foo==1.0.0 (from file://[TEMP_DIR]/) + "); + Ok(()) } @@ -5044,6 +5120,35 @@ fn run_with_env_file() -> Result<()> { ----- stderr ----- "); + context.temp_dir.child(".file").write_str(indoc! { " + UV_PYTHON_SEARCH_PATH=.no-python + THE_EMPIRE_VARIABLE=palpatine + REBEL_1=leia_organa + REBEL_2=obi_wan_kenobi + REBEL_3=C3PO + " + })?; + + uv_snapshot!(context.filters(), context.run() + .arg("--no-project") + .arg("--no-managed-python") + .arg("--python").arg("3.12") + .arg("--env-file").arg(".file") + .arg("test.py") + .env_remove(EnvVars::VIRTUAL_ENV) + .env_remove(EnvVars::UV_PYTHON_SEARCH_PATH) + .env(EnvVars::PATH, context.python_path()), @" + success: true + exit_code: 0 + ----- stdout ----- + palpatine + leia_organa + obi_wan_kenobi + C3PO + + ----- stderr ----- + "); + Ok(()) } @@ -6665,60 +6770,6 @@ fn run_target_workspace_discovery() -> Result<()> { Ok(()) } -/// Test that `--preview` enables target workspace discovery. -#[test] -fn run_target_workspace_discovery_preview_flag() -> Result<()> { - let context = setup_target_workspace_discovery_context()?; - - context.temp_dir.child("uv.toml").write_str("bad")?; - context.temp_dir.child("pyproject.toml").write_str("bad")?; - - uv_snapshot!(context.filters(), context.run().arg("--preview").arg("project/script.py").env_remove(EnvVars::VIRTUAL_ENV), @" - success: true - exit_code: 0 - ----- stdout ----- - success - - ----- stderr ----- - Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] - Creating virtual environment at: project/.venv - Resolved 2 packages in [TIME] - Prepared 2 packages in [TIME] - Installed 2 packages in [TIME] - + foo==1.0.0 (from file://[TEMP_DIR]/project) - + iniconfig==2.0.0 - "); - - Ok(()) -} - -/// Test that `UV_PREVIEW=1` enables target workspace discovery. -#[test] -fn run_target_workspace_discovery_uv_preview_env() -> Result<()> { - let context = setup_target_workspace_discovery_context()?; - - context.temp_dir.child("uv.toml").write_str("bad")?; - context.temp_dir.child("pyproject.toml").write_str("bad")?; - - uv_snapshot!(context.filters(), context.run().env("UV_PREVIEW", "1").arg("project/script.py").env_remove(EnvVars::VIRTUAL_ENV), @" - success: true - exit_code: 0 - ----- stdout ----- - success - - ----- stderr ----- - Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] - Creating virtual environment at: project/.venv - Resolved 2 packages in [TIME] - Prepared 2 packages in [TIME] - Installed 2 packages in [TIME] - + foo==1.0.0 (from file://[TEMP_DIR]/project) - + iniconfig==2.0.0 - "); - - Ok(()) -} - /// Test that `--preview-features target-workspace-discovery` works with a bare script /// filename (no directory component), which would otherwise cause `Path::parent()` to /// return an empty path. diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 14c0e317c9e95..60c27b2b57ddf 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -8638,6 +8638,7 @@ fn preview_features() { AzureEndpoint, TomlBackwardsCompatibility, MalwareCheck, + VenvSafeClear, ], }, python_preference: Managed, @@ -8921,6 +8922,7 @@ fn preview_features() { AzureEndpoint, TomlBackwardsCompatibility, MalwareCheck, + VenvSafeClear, ], }, python_preference: Managed, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 5addc44a9704e..fd6054d162137 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -11025,6 +11025,80 @@ fn sync_git_path_archive() -> Result<()> { Ok(()) } +/// Reject a locked Git wheel archive when Git LFS was requested but could not be initialized. +#[test] +#[cfg(feature = "test-git")] +fn sync_git_path_archive_missing_lfs() -> Result<()> { + let context = uv_test::test_context!("3.13"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.13" + dependencies = ["iniconfig"] + + [tool.uv.sources] + iniconfig = { git = "https://github.com/astral-sh/archive-in-git-test", path = "archives/iniconfig-2.0.0-py3-none-any.whl", lfs = true } + "#, + )?; + + let lock = context.temp_dir.child("uv.lock"); + lock.write_str( + r#" + version = 1 + revision = 3 + requires-python = ">=3.13" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "foo" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "iniconfig" }, + ] + + [package.metadata] + requires-dist = [{ name = "iniconfig", git = "https://github.com/astral-sh/archive-in-git-test?path=archives%2Finiconfig-2.0.0-py3-none-any.whl&lfs=true" }] + + [[package]] + name = "iniconfig" + version = "2.0.0" + source = { git = "https://github.com/astral-sh/archive-in-git-test?path=archives%2Finiconfig-2.0.0-py3-none-any.whl&lfs=true#bb7ce6abf9f90544767701de5b7b0c7802dc642b" } + wheels = [ + { filename = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" }, + ] + "#, + )?; + + uv_snapshot!( + context.filters(), + context + .sync() + .arg("--frozen") + .env(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED, "1"), + @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download `iniconfig @ git+https://github.com/astral-sh/archive-in-git-test@bb7ce6abf9f90544767701de5b7b0c7802dc642b#path=archives/iniconfig-2.0.0-py3-none-any.whl&lfs=true` + ├─▶ The wheel `git+https://github.com/astral-sh/archive-in-git-test@bb7ce6abf9f90544767701de5b7b0c7802dc642b#path=archives/iniconfig-2.0.0-py3-none-any.whl&lfs=true` is missing Git LFS artifacts. + ╰─▶ Git LFS extension not found. Ensure that Git LFS is installed and available. + + hint: `iniconfig` (v2.0.0) was included because `foo` (v0.1.0) depends on `iniconfig` + "### + ); + + Ok(()) +} + /// The project itself is marked as an editable dependency, but under the wrong name. The project /// is a package. #[test] @@ -11351,6 +11425,144 @@ fn lock_git_poetry_path_dependency() -> Result<()> { Ok(()) } +/// Lock a Git repository with generated metadata that references an archive within the repository. +/// +/// See: +#[test] +#[cfg(feature = "test-git")] +fn lock_git_metadata_archive_dependency() -> Result<()> { + let context = uv_test::test_context!("3.13"); + + let repository = context.temp_dir.child("repository"); + repository.child("root").create_dir_all()?; + repository.child("root/root.py").touch()?; + repository.child("root/archives").create_dir_all()?; + fs_err::copy( + context + .workspace_root + .join("test/links/basic_package-0.1.0-py3-none-any.whl"), + repository + .child("root/archives/basic_package-0.1.0-py3-none-any.whl") + .path(), + )?; + repository.child("root/setup.py").write_str(indoc! {r#" + from pathlib import Path + from setuptools import setup + + wheel = Path(__file__).parent / "archives" / "basic_package-0.1.0-py3-none-any.whl" + + setup( + name="root", + version="0.1.0", + py_modules=["root"], + install_requires=[f"basic-package @ {wheel.as_uri()}"], + ) + "#})?; + + Command::new("git") + .arg("init") + .arg(repository.path()) + .assert() + .success(); + Command::new("git") + .arg("-C") + .arg(repository.path()) + .arg("add") + .arg(".") + .assert() + .success(); + Command::new("git") + .arg("-C") + .arg(repository.path()) + .arg("-c") + .arg("user.name=Example") + .arg("-c") + .arg("user.email=example@example.com") + .arg("commit") + .arg("-m") + .arg("Initial commit") + .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") + .assert() + .success(); + + let repository_url = Url::from_directory_path(repository.path()) + .map_err(|()| anyhow!("failed to convert repository path to file URL"))?; + context + .temp_dir + .child("pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.13" + dependencies = ["root"] + + [tool.uv.sources] + root = {{ git = "{repository_url}", subdirectory = "root" }} + "#})?; + + uv_snapshot!(context.filters(), context.lock().arg("--no-cache"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + let mut filters = context.filters(); + filters.push((r"#[0-9a-f]{40}", "#[COMMIT]")); + + insta::with_settings!( + { + filters => filters, + }, + { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.13" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "basic-package" + version = "0.1.0" + source = { git = "file://[TEMP_DIR]/repository/?path=root%2Farchives%2Fbasic_package-0.1.0-py3-none-any.whl#[COMMIT]" } + wheels = [ + { filename = "basic_package-0.1.0-py3-none-any.whl", hash = "sha256:7b6229db79b5800e4e98a351b5628c1c8a944533a2d428aeeaa7275a30d4ea82" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "root" }, + ] + + [package.metadata] + requires-dist = [{ name = "root", git = "file://[TEMP_DIR]/repository/?subdirectory=root" }] + + [[package]] + name = "root" + version = "0.1.0" + source = { git = "file://[TEMP_DIR]/repository/?subdirectory=root#[COMMIT]" } + dependencies = [ + { name = "basic-package" }, + ] + "# + ); + } + ); + + Ok(()) +} + /// Sync a package with multiple wheels at the same version, differing only in the build tag. We /// should choose the wheel with the highest build tag. #[test] @@ -15167,7 +15379,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) "); @@ -15276,7 +15488,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) "); @@ -15303,7 +15515,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) "); @@ -15335,7 +15547,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) "); @@ -15361,7 +15573,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) "); @@ -15425,7 +15637,7 @@ fn sync_git_lfs() -> Result<()> { Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] - - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + - test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb) + test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@261c828b8e05251f3a3e4f6b47b149d691c7efbb#lfs=true) "); @@ -15938,6 +16150,35 @@ fn toggle_workspace_editable() -> Result<()> { ); }); + uv_snapshot!(context.filters(), context.sync().arg("--no-editable-package").arg("child"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ child==0.1.0 (from file://[TEMP_DIR]/child) + "); + + assert!(!context.site_packages().join("_child.pth").exists()); + + uv_snapshot!(context.filters(), context.sync().arg("--editable"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ child==0.1.0 (from file://[TEMP_DIR]/child) + "); + + assert!(context.site_packages().join("_child.pth").exists()); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str( r#" @@ -15962,7 +16203,6 @@ fn toggle_workspace_editable() -> Result<()> { ----- stderr ----- Resolved 3 packages in [TIME] - Prepared 1 package in [TIME] Uninstalled 1 package in [TIME] Installed 1 package in [TIME] ~ child==0.1.0 (from file://[TEMP_DIR]/child) diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs index 3dfa062b17402..8565ccc36e41a 100644 --- a/crates/uv/tests/it/tool_install.rs +++ b/crates/uv/tests/it/tool_install.rs @@ -1,9 +1,13 @@ #[cfg(any(feature = "test-git", feature = "test-git-lfs"))] use std::collections::BTreeSet; +#[cfg(feature = "test-git")] +use std::ffi::OsString; use std::process::Command; use anyhow::Result; use assert_cmd::assert::OutputAssertExt; +#[cfg(feature = "test-git")] +use assert_fs::fixture::ChildPath; use assert_fs::{ assert::PathAssert, fixture::{FileTouch, FileWriteStr, PathChild, PathCreateDir}, @@ -16,6 +20,32 @@ use uv_static::EnvVars; use uv_test::uv_snapshot; +#[cfg(feature = "test-git")] +fn tool_install_git_path(bin_dir: &ChildPath) -> OsString { + let mut paths = BTreeSet::new(); + paths.insert(bin_dir.to_path_buf()); + paths.insert( + which::which("git") + .expect("Failed to find `git` executable.") + .parent() + .expect("Failed to find `git` executable directory.") + .to_path_buf(), + ); + + // Git Submodule in macOS seems to rely on `sed`. + if cfg!(target_os = "macos") { + paths.insert( + which::which("sed") + .expect("Failed to find `sed` executable.") + .parent() + .expect("Failed to find `sed` executable directory.") + .to_path_buf(), + ); + } + + std::env::join_paths(paths).unwrap() +} + #[test] fn tool_install() { let context = uv_test::test_context!("3.12") @@ -217,6 +247,168 @@ fn tool_install_relative_exclude_newer_receipt_preserves_span() { }); } +#[test] +fn tool_install_from_directory_ignores_global_pin_outside_requires_python_range() { + let context = uv_test::test_context_with_versions!(&["3.13", "3.12", "3.11"]) + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let foo_dir = context.temp_dir.child("foo"); + foo_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.11,<3.13" + dependencies = [] + + [project.scripts] + foo = "foo.main:run" + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "# + }) + .unwrap(); + foo_dir + .child("src") + .child("foo") + .child("__init__.py") + .touch() + .unwrap(); + foo_dir + .child("src") + .child("foo") + .child("main.py") + .write_str(indoc! {r#" + import sys + + def run(): + print(f"{sys.version_info.major}.{sys.version_info.minor}") + "# + }) + .unwrap(); + + context + .python_pin() + .arg("3.13") + .arg("--global") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_install() + .arg(foo_dir.as_os_str()) + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo) + Installed 1 executable: foo + "); + + uv_snapshot!(context.filters(), Command::new("foo") + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + 3.12 + + ----- stderr ----- + "); +} + +#[test] +fn tool_install_from_directory_uses_global_pin_within_requires_python_range() { + let context = uv_test::test_context_with_versions!(&["3.13", "3.12", "3.11"]) + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let foo_dir = context.temp_dir.child("foo"); + foo_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.11,<3.13" + dependencies = [] + + [project.scripts] + foo = "foo.main:run" + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "# + }) + .unwrap(); + foo_dir + .child("src") + .child("foo") + .child("__init__.py") + .touch() + .unwrap(); + foo_dir + .child("src") + .child("foo") + .child("main.py") + .write_str(indoc! {r#" + import sys + + def run(): + print(f"{sys.version_info.major}.{sys.version_info.minor}") + "# + }) + .unwrap(); + + context + .python_pin() + .arg("3.11") + .arg("--global") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_install() + .arg(foo_dir.as_os_str()) + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo) + Installed 1 executable: foo + "); + + uv_snapshot!(context.filters(), Command::new("foo") + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + 3.11 + + ----- stderr ----- + "); +} + #[test] fn tool_install_python_from_global_version_file() { let context = uv_test::test_context_with_versions!(&["3.11", "3.12", "3.13"]) @@ -2932,26 +3124,7 @@ fn tool_install_git() { let context = uv_test::test_context!("3.12").with_filtered_exe_suffix(); let tool_dir = context.temp_dir.child("tools"); let bin_dir = context.temp_dir.child("bin"); - let mut paths = BTreeSet::new(); - - // Avoid removing `git` from PATH - let git_path = which::which("git") - .expect("Failed to find `git` executable.") - .parent() - .expect("Failed to find `git` executable directory.") - .to_path_buf(); - paths.insert(bin_dir.to_path_buf()); - paths.insert(git_path); - // Git Submodule in macos seems to rely on `sed`. - if cfg!(target_os = "macos") { - let sed_path = which::which("sed") - .expect("Failed to find `sed` executable.") - .parent() - .expect("Failed to find `sed` executable directory.") - .to_path_buf(); - paths.insert(sed_path); - } - let path = std::env::join_paths(paths).unwrap(); + let path = tool_install_git_path(&bin_dir); // Unnamed Git Install uv_snapshot!(context.filters(), context.tool_install() @@ -3020,6 +3193,87 @@ fn tool_install_git() { assert!(executable.exists()); } +/// Test that installing a tool from Git uses statically available `requires-python` metadata +/// before selecting a global Python pin. +#[test] +#[cfg(feature = "test-git")] +fn tool_install_git_infers_static_requires_python() { + let context = uv_test::test_context_with_versions!(&["3.12", "3.11"]) + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + let path = tool_install_git_path(&bin_dir); + + context + .python_pin() + .arg("3.11") + .arg("--global") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_install() + .arg("git+https://github.com/astral-sh/uv-dynamic-requires-python-test@75a612dc87fc215e999a25a0efc376cbf9831afa#subdirectory=static") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, path.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + static-requires-python-tool==0.1.0 (from git+https://github.com/astral-sh/uv-dynamic-requires-python-test@75a612dc87fc215e999a25a0efc376cbf9831afa#subdirectory=static) + Installed 1 executable: static-requires-python-tool + "); + + uv_snapshot!(context.filters(), Command::new("static-requires-python-tool") + .env(EnvVars::PATH, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + 3.12 + + ----- stderr ----- + "); +} + +/// Test that installing a tool from Git does not infer dynamic `requires-python` metadata. +#[test] +#[cfg(feature = "test-git")] +fn tool_install_git_does_not_infer_dynamic_requires_python() { + let context = uv_test::test_context_with_versions!(&["3.12", "3.11"]) + .with_filtered_counts() + .with_filtered_exe_suffix(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + let path = tool_install_git_path(&bin_dir); + + context + .python_pin() + .arg("3.11") + .arg("--global") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_install() + .arg("git+https://github.com/astral-sh/uv-dynamic-requires-python-test@75a612dc87fc215e999a25a0efc376cbf9831afa#subdirectory=dynamic") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()) + .env(EnvVars::PATH, path.as_os_str()), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because the current Python version (3.11.[X]) does not satisfy Python>=3.12,<3.13 and dynamic-requires-python-tool==0.1.0 depends on Python>=3.12,<3.13, we can conclude that dynamic-requires-python-tool==0.1.0 cannot be used. + And because only dynamic-requires-python-tool==0.1.0 is available and you require dynamic-requires-python-tool, we can conclude that your requirements are unsatisfiable. + "); +} + /// Test installing a tool with a Git LFS enabled requirement. #[test] #[cfg(feature = "test-git-lfs")] diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index a12045537ddf8..226612d618565 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -1065,6 +1065,73 @@ fn tool_run_git() { "); } +/// Test that running a tool from Git uses statically available `requires-python` metadata before +/// selecting a global Python pin. +#[test] +#[cfg(feature = "test-git")] +fn tool_run_git_infers_static_requires_python() { + let context = uv_test::test_context_with_versions!(&["3.12", "3.11"]).with_filtered_counts(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + context + .python_pin() + .arg("3.11") + .arg("--global") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg("git+https://github.com/astral-sh/uv-dynamic-requires-python-test@75a612dc87fc215e999a25a0efc376cbf9831afa#subdirectory=static") + .arg("static-requires-python-tool") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + 3.12 + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + static-requires-python-tool==0.1.0 (from git+https://github.com/astral-sh/uv-dynamic-requires-python-test@75a612dc87fc215e999a25a0efc376cbf9831afa#subdirectory=static) + "); +} + +/// Test that running a tool from Git does not infer dynamic `requires-python` metadata. +#[test] +#[cfg(feature = "test-git")] +fn tool_run_git_does_not_infer_dynamic_requires_python() { + let context = uv_test::test_context_with_versions!(&["3.12", "3.11"]).with_filtered_counts(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + context + .python_pin() + .arg("3.11") + .arg("--global") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg("git+https://github.com/astral-sh/uv-dynamic-requires-python-test@75a612dc87fc215e999a25a0efc376cbf9831afa#subdirectory=dynamic") + .arg("dynamic-requires-python-tool") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving tool dependencies: + ╰─▶ Because the current Python version (3.11.[X]) does not satisfy Python>=3.12,<3.13 and dynamic-requires-python-tool==0.1.0 depends on Python>=3.12,<3.13, we can conclude that dynamic-requires-python-tool==0.1.0 cannot be used. + And because only dynamic-requires-python-tool==0.1.0 is available and you require dynamic-requires-python-tool, we can conclude that your requirements are unsatisfiable. + "); +} + /// Test running a tool with a Git LFS enabled requirement. #[test] #[cfg(feature = "test-git-lfs")] @@ -2622,6 +2689,148 @@ fn tool_run_python_from() { "); } +#[test] +fn tool_run_from_directory_uses_global_pin_when_within_requires_python_range() { + let context = + uv_test::test_context_with_versions!(&["3.13", "3.12", "3.11"]).with_filtered_counts(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let foo_dir = context.temp_dir.child("foo"); + foo_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.11,<3.13" + dependencies = [] + + [project.scripts] + foo = "foo.main:run" + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "# + }) + .unwrap(); + foo_dir + .child("src") + .child("foo") + .child("__init__.py") + .touch() + .unwrap(); + foo_dir + .child("src") + .child("foo") + .child("main.py") + .write_str(indoc! {r#" + import sys + + def run(): + print(f"{sys.version_info.major}.{sys.version_info.minor}") + "# + }) + .unwrap(); + + context + .python_pin() + .arg("3.11") + .arg("--global") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg(foo_dir.as_os_str()) + .arg("foo") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + 3.11 + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo) + "); +} + +#[test] +fn tool_run_from_directory_ignores_global_pin_outside_requires_python_range() { + let context = + uv_test::test_context_with_versions!(&["3.13", "3.12", "3.11"]).with_filtered_counts(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let foo_dir = context.temp_dir.child("foo"); + foo_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.11,<3.13" + dependencies = [] + + [project.scripts] + foo = "foo.main:run" + + [build-system] + requires = ["uv_build>=0.7,<10000"] + build-backend = "uv_build" + "# + }) + .unwrap(); + foo_dir + .child("src") + .child("foo") + .child("__init__.py") + .touch() + .unwrap(); + foo_dir + .child("src") + .child("foo") + .child("main.py") + .write_str(indoc! {r#" + import sys + + def run(): + print(f"{sys.version_info.major}.{sys.version_info.minor}") + "# + }) + .unwrap(); + + context + .python_pin() + .arg("3.13") + .arg("--global") + .assert() + .success(); + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--from") + .arg(foo_dir.as_os_str()) + .arg("foo") + .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str()) + .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @" + success: true + exit_code: 0 + ----- stdout ----- + 3.12 + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + foo==0.1.0 (from file://[TEMP_DIR]/foo) + "); +} + #[test] fn run_with_env_file() -> anyhow::Result<()> { let context = uv_test::test_context!("3.12").with_filtered_counts(); diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index be8442331c9b9..f5aca90a5fe0c 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -1079,6 +1079,7 @@ fn non_empty_dir_exists() -> Result<()> { ----- stderr ----- Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] Creating virtual environment at: .venv + warning: The `--clear` option will remove the existing directory at `.venv` even though it is not a virtual environment. This will become an error in a future release. Use `--force` to suppress this warning, or `--preview-features venv-safe-clear` to error on this now. Activate with: source .venv/[BIN]/activate " ); @@ -1086,6 +1087,67 @@ fn non_empty_dir_exists() -> Result<()> { Ok(()) } +#[test] +fn non_empty_dir_exists_clear_preview() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]); + let marker = context.temp_dir.child("file"); + marker.touch()?; + + uv_snapshot!(context.filters(), context.venv() + .arg(".") + .arg("--clear") + .arg("--preview-features") + .arg("venv-safe-clear") + .arg("--python") + .arg("3.12"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: . + error: Failed to create virtual environment + Caused by: uv will not clear a directory that is not a virtual environment + + hint: Use the `--force` flag to remove the existing directory anyway + "); + + marker.assert(predicates::path::is_file()); + + Ok(()) +} + +#[test] +fn non_empty_dir_exists_clear_preview_force() -> Result<()> { + let context = uv_test::test_context_with_versions!(&["3.12"]); + let directory = context.temp_dir.child("not-a-virtualenv"); + directory.create_dir_all()?; + directory.child("file").touch()?; + + uv_snapshot!(context.filters(), context.venv() + .arg(directory.as_os_str()) + .arg("--clear") + .arg("--force") + .arg("--preview-features") + .arg("venv-safe-clear") + .arg("--python") + .arg("3.12"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: not-a-virtualenv + Activate with: source not-a-virtualenv/[BIN]/activate + "); + + directory.child("file").assert(predicates::path::missing()); + + Ok(()) +} + #[test] fn non_empty_dir_exists_allow_existing() -> Result<()> { let context = uv_test::test_context_with_versions!(&["3.12"]); diff --git a/crates/uv/tests/it/workspace_metadata.rs b/crates/uv/tests/it/workspace_metadata.rs index f70af696ad394..a1c970b104f83 100644 --- a/crates/uv/tests/it/workspace_metadata.rs +++ b/crates/uv/tests/it/workspace_metadata.rs @@ -1,9 +1,56 @@ +use std::path::Path; + use anyhow::Result; use assert_cmd::assert::OutputAssertExt; -use assert_fs::fixture::PathChild; +use assert_fs::fixture::{FileWriteStr, PathChild}; +use async_zip::base::write::ZipFileWriter; +use async_zip::{Compression, ZipEntryBuilder}; +use futures::executor::block_on; +use url::Url; use uv_test::{copy_dir_ignore, uv_snapshot}; +fn write_wheel( + path: &Path, + name: &str, + dist_info_prefix: &str, + files: &[(&str, &str)], +) -> Result<()> { + let mut writer = ZipFileWriter::new(Vec::new()); + let mut record = Vec::new(); + + for (file_path, contents) in files { + let entry = ZipEntryBuilder::new((*file_path).into(), Compression::Stored); + block_on(writer.write_entry_whole(entry, contents.as_bytes()))?; + record.push(format!("{file_path},,")); + } + + let metadata_path = format!("{dist_info_prefix}.dist-info/METADATA"); + let entry = ZipEntryBuilder::new(metadata_path.clone().into(), Compression::Stored); + block_on(writer.write_entry_whole( + entry, + format!("Metadata-Version: 2.1\nName: {name}\nVersion: 0.1.0\n").as_bytes(), + ))?; + record.push(format!("{metadata_path},,")); + + let wheel_path = format!("{dist_info_prefix}.dist-info/WHEEL"); + let entry = ZipEntryBuilder::new(wheel_path.clone().into(), Compression::Stored); + block_on(writer.write_entry_whole( + entry, + b"Wheel-Version: 1.0\nGenerator: uv-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n", + ))?; + record.push(format!("{wheel_path},,")); + + let record_path = format!("{dist_info_prefix}.dist-info/RECORD"); + record.push(format!("{record_path},,")); + let entry = ZipEntryBuilder::new(record_path.into(), Compression::Stored); + let record = format!("{}\n", record.join("\n")); + block_on(writer.write_entry_whole(entry, record.as_bytes()))?; + + fs_err::write(path, block_on(writer.close())?)?; + Ok(()) +} + /// Test basic metadata output for a simple workspace with one member. #[test] fn workspace_metadata_simple() { @@ -55,6 +102,346 @@ fn workspace_metadata_simple() { ); } +#[test] +fn workspace_metadata_module_owners_from_locked_wheels() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let gpu_a = context.temp_dir.child("gpu_a-0.1.0-py3-none-any.whl"); + write_wheel(gpu_a.path(), "gpu-a", "gpu_a-0.1.0", &[("gpu/a.py", "")])?; + + let gpu_b = context.temp_dir.child("gpu_b-0.1.0-py3-none-any.whl"); + write_wheel(gpu_b.path(), "gpu-b", "gpu_b-0.1.0", &[("gpu/b.py", "")])?; + + let typing_extensions = context + .temp_dir + .child("typing_extensions-0.1.0-py3-none-any.whl"); + write_wheel( + typing_extensions.path(), + "typing-extensions", + "typing_extensions-0.1.0", + &[ + ("typing_extensions.py", ""), + ("café.py", ""), + ("bogus.pypynonsense.so", ""), + ("bytecode/__pycache__/compiled.cpython-312.pyc", ""), + ], + )?; + + let gpu_a_url = Url::from_file_path(gpu_a.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + let gpu_b_url = Url::from_file_path(gpu_b.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + let typing_extensions_url = Url::from_file_path(typing_extensions.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + context + .temp_dir + .child("pyproject.toml") + .write_str(&format!( + r#"[project] +name = "module-owner-root" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "gpu-a @ {gpu_a_url}", + "gpu-b @ {gpu_b_url}", + "typing-extensions @ {typing_extensions_url}", +] +"# + ))?; + + let mut filters = context.filters(); + filters.push((r#""sha256": "[0-9a-f]{64}""#, r#""sha256": "[SHA256]""#)); + + uv_snapshot!(filters, context.workspace_metadata().arg("--sync"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + { + "schema": { + "version": "preview" + }, + "workspace_root": "[TEMP_DIR]/", + "requires_python": ">=3.12", + "conflicts": { + "sets": [] + }, + "module_owners": { + "café": [ + { + "package_id": "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + } + ], + "gpu": [ + { + "package_id": "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" + }, + { + "package_id": "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + } + ], + "gpu.a": [ + { + "package_id": "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" + } + ], + "gpu.b": [ + { + "package_id": "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + } + ], + "typing_extensions": [ + { + "package_id": "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + } + ] + }, + "members": [ + { + "name": "module-owner-root", + "path": "[TEMP_DIR]/", + "id": "module-owner-root==0.1.0@virtual+[TEMP_DIR]/" + } + ], + "resolution": { + "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl": { + "name": "gpu-a", + "version": "0.1.0", + "source": { + "path": "[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" + }, + "kind": "package", + "dependencies": [], + "wheels": [ + { + "hashes": { + "sha256": "[SHA256]" + }, + "filename": "gpu_a-0.1.0-py3-none-any.whl" + } + ] + }, + "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl": { + "name": "gpu-b", + "version": "0.1.0", + "source": { + "path": "[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + }, + "kind": "package", + "dependencies": [], + "wheels": [ + { + "hashes": { + "sha256": "[SHA256]" + }, + "filename": "gpu_b-0.1.0-py3-none-any.whl" + } + ] + }, + "module-owner-root==0.1.0@virtual+[TEMP_DIR]/": { + "name": "module-owner-root", + "version": "0.1.0", + "source": { + "virtual": "[TEMP_DIR]/" + }, + "kind": "package", + "dependencies": [ + { + "id": "gpu-a==0.1.0@path+[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl" + }, + { + "id": "gpu-b==0.1.0@path+[TEMP_DIR]/gpu_b-0.1.0-py3-none-any.whl" + }, + { + "id": "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + } + ] + }, + "typing-extensions==0.1.0@path+[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl": { + "name": "typing-extensions", + "version": "0.1.0", + "source": { + "path": "[TEMP_DIR]/typing_extensions-0.1.0-py3-none-any.whl" + }, + "kind": "package", + "dependencies": [], + "wheels": [ + { + "hashes": { + "sha256": "[SHA256]" + }, + "filename": "typing_extensions-0.1.0-py3-none-any.whl" + } + ] + } + } + } + + ----- stderr ----- + warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + Resolved 4 packages in [TIME] + "#); + + Ok(()) +} + +#[test] +fn workspace_metadata_module_owners_use_installed_package_id() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let py311_dir = context.temp_dir.child("py311"); + fs_err::create_dir_all(py311_dir.path())?; + let module_owner_311 = py311_dir.child("module_owner-0.1.0-py3-none-any.whl"); + write_wheel( + module_owner_311.path(), + "module-owner", + "module_owner-0.1.0", + &[("shared.py", "")], + )?; + + let py312_dir = context.temp_dir.child("py312"); + fs_err::create_dir_all(py312_dir.path())?; + let module_owner_312 = py312_dir.child("module_owner-0.1.0-py3-none-any.whl"); + write_wheel( + module_owner_312.path(), + "module-owner", + "module_owner-0.1.0", + &[("shared.py", "")], + )?; + + let module_owner_311_url = Url::from_file_path(module_owner_311.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + let module_owner_312_url = Url::from_file_path(module_owner_312.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + context + .temp_dir + .child("pyproject.toml") + .write_str(&format!( + r#"[project] +name = "module-owner-root" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "module-owner @ {module_owner_311_url} ; python_version < '3.12'", + "module-owner @ {module_owner_312_url} ; python_version >= '3.12'", +] +"# + ))?; + + let assert = context + .workspace_metadata() + .arg("--sync") + .assert() + .success(); + let metadata: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?; + let module_owners = serde_json::to_string_pretty(&metadata["module_owners"])?; + + insta::with_settings!({ filters => context.filters() }, { + insta::assert_snapshot!(module_owners, @r#" + { + "shared": [ + { + "package_id": "module-owner==0.1.0@path+[TEMP_DIR]/py312/module_owner-0.1.0-py3-none-any.whl" + } + ] + } + "#); + }); + + Ok(()) +} + +#[test] +fn workspace_metadata_module_owners_ignore_stale_virtual_package() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let stale_owner = context + .temp_dir + .child("module_owner_root-0.1.0-py3-none-any.whl"); + write_wheel( + stale_owner.path(), + "module-owner-root", + "module_owner_root-0.1.0", + &[("stale.py", "")], + )?; + + context.temp_dir.child("pyproject.toml").write_str( + r#"[project] +name = "module-owner-root" +version = "0.1.0" +requires-python = ">=3.12" + +[tool.uv] +package = false +"#, + )?; + + context + .pip_install() + .arg(stale_owner.path()) + .assert() + .success(); + + let assert = context + .workspace_metadata() + .arg("--sync") + .assert() + .success(); + let metadata: serde_json::Value = serde_json::from_slice(&assert.get_output().stdout)?; + let module_owners = if let Some(module_owners) = metadata.get("module_owners") { + serde_json::to_string_pretty(module_owners)? + } else { + "".to_string() + }; + + insta::assert_snapshot!(module_owners, @""); + + Ok(()) +} + +#[test] +fn workspace_metadata_module_owners_failure_is_error() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let gpu_a = context.temp_dir.child("gpu_a-0.1.0-py3-none-any.whl"); + write_wheel(gpu_a.path(), "gpu-a", "gpu_a-0.1.0", &[("gpu/a.py", "")])?; + + let gpu_a_url = Url::from_file_path(gpu_a.path()) + .map_err(|()| anyhow::anyhow!("failed to convert wheel path to file URL"))?; + + context + .temp_dir + .child("pyproject.toml") + .write_str(&format!( + r#"[project] +name = "module-owner-root" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "gpu-a @ {gpu_a_url}", +] +"# + ))?; + + context.lock().assert().success(); + fs_err::remove_file(gpu_a.path())?; + + uv_snapshot!(context.filters(), context.workspace_metadata().arg("--frozen").arg("--sync"), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: The `uv workspace metadata` command is experimental and may change without warning. Pass `--preview-features workspace-metadata` to disable this warning. + error: Failed to collect module owners + Caused by: Failed to determine installation plan + Caused by: Distribution not found at: file://[TEMP_DIR]/gpu_a-0.1.0-py3-none-any.whl + "#); + + Ok(()) +} + /// Test metadata for a root workspace (workspace with a root package). #[test] #[cfg(feature = "test-pypi")] diff --git a/docs/concepts/build-backend.md b/docs/concepts/build-backend.md index 0b803751f6abe..e5f28ad14ca7d 100644 --- a/docs/concepts/build-backend.md +++ b/docs/concepts/build-backend.md @@ -31,7 +31,7 @@ To use uv as a build backend in an existing project, add `uv_build` to the ```toml title="pyproject.toml" [build-system] -requires = ["uv_build>=0.11.16,<0.12"] +requires = ["uv_build>=0.11.17,<0.12"] build-backend = "uv_build" ``` diff --git a/docs/concepts/projects/init.md b/docs/concepts/projects/init.md index 001716ae1e58e..580d44db72011 100644 --- a/docs/concepts/projects/init.md +++ b/docs/concepts/projects/init.md @@ -113,7 +113,7 @@ dependencies = [] example-pkg = "example_pkg:main" [build-system] -requires = ["uv_build>=0.11.16,<0.12"] +requires = ["uv_build>=0.11.17,<0.12"] build-backend = "uv_build" ``` @@ -136,7 +136,7 @@ dependencies = [] example-pkg = "example_pkg:main" [build-system] -requires = ["uv_build>=0.11.16,<0.12"] +requires = ["uv_build>=0.11.17,<0.12"] build-backend = "uv_build" ``` @@ -197,7 +197,7 @@ requires-python = ">=3.11" dependencies = [] [build-system] -requires = ["uv_build>=0.11.16,<0.12"] +requires = ["uv_build>=0.11.17,<0.12"] build-backend = "uv_build" ``` diff --git a/docs/concepts/projects/workspaces.md b/docs/concepts/projects/workspaces.md index 6618a1fbe0cf7..677992e7f2575 100644 --- a/docs/concepts/projects/workspaces.md +++ b/docs/concepts/projects/workspaces.md @@ -75,7 +75,7 @@ bird-feeder = { workspace = true } members = ["packages/*"] [build-system] -requires = ["uv_build>=0.11.16,<0.12"] +requires = ["uv_build>=0.11.17,<0.12"] build-backend = "uv_build" ``` @@ -106,7 +106,7 @@ tqdm = { git = "https://github.com/tqdm/tqdm" } members = ["packages/*"] [build-system] -requires = ["uv_build>=0.11.16,<0.12"] +requires = ["uv_build>=0.11.17,<0.12"] build-backend = "uv_build" ``` @@ -188,7 +188,7 @@ dependencies = ["bird-feeder", "tqdm>=4,<5"] bird-feeder = { path = "packages/bird-feeder" } [build-system] -requires = ["uv_build>=0.11.16,<0.12"] +requires = ["uv_build>=0.11.17,<0.12"] build-backend = "uv_build" ``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index cc16683b8b937..a8f4ca3b1c09b 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -25,7 +25,7 @@ uv provides a standalone installer to download and install uv: Request a specific version by including it in the URL: ```console - $ curl -LsSf https://astral.sh/uv/0.11.16/install.sh | sh + $ curl -LsSf https://astral.sh/uv/0.11.17/install.sh | sh ``` === "Windows" @@ -41,7 +41,7 @@ uv provides a standalone installer to download and install uv: Request a specific version by including it in the URL: ```pwsh-session - PS> powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/0.11.16/install.ps1 | iex" + PS> powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/0.11.17/install.ps1 | iex" ``` !!! tip diff --git a/docs/guides/integration/aws-lambda.md b/docs/guides/integration/aws-lambda.md index fdeb9cec3a68f..a43e4daf37fbb 100644 --- a/docs/guides/integration/aws-lambda.md +++ b/docs/guides/integration/aws-lambda.md @@ -92,7 +92,7 @@ the second stage, we'll copy this directory over to the final image, omitting th other unnecessary files. ```dockerfile title="Dockerfile" -FROM ghcr.io/astral-sh/uv:0.11.16 AS uv +FROM ghcr.io/astral-sh/uv:0.11.17 AS uv # First, bundle the dependencies into the task root. FROM public.ecr.aws/lambda/python:3.13 AS builder @@ -334,7 +334,7 @@ And confirm that opening http://127.0.0.1:8000/ in a web browser displays, "Hell Finally, we'll update the Dockerfile to include the local library in the deployment package: ```dockerfile title="Dockerfile" -FROM ghcr.io/astral-sh/uv:0.11.16 AS uv +FROM ghcr.io/astral-sh/uv:0.11.17 AS uv # First, bundle the dependencies into the task root. FROM public.ecr.aws/lambda/python:3.13 AS builder diff --git a/docs/guides/integration/docker.md b/docs/guides/integration/docker.md index 16db58b24d08d..b3a68158867c9 100644 --- a/docs/guides/integration/docker.md +++ b/docs/guides/integration/docker.md @@ -31,7 +31,7 @@ $ docker run --rm -it ghcr.io/astral-sh/uv:debian uv --help The following distroless images are available: - `ghcr.io/astral-sh/uv:latest` -- `ghcr.io/astral-sh/uv:{major}.{minor}.{patch}`, e.g., `ghcr.io/astral-sh/uv:0.11.16` +- `ghcr.io/astral-sh/uv:{major}.{minor}.{patch}`, e.g., `ghcr.io/astral-sh/uv:0.11.17` - `ghcr.io/astral-sh/uv:{major}.{minor}`, e.g., `ghcr.io/astral-sh/uv:0.8` (the latest patch version) @@ -92,7 +92,7 @@ And the following derived images are available: As with the distroless image, each derived image is published with uv version tags as `ghcr.io/astral-sh/uv:{major}.{minor}.{patch}-{base}` and -`ghcr.io/astral-sh/uv:{major}.{minor}-{base}`, e.g., `ghcr.io/astral-sh/uv:0.11.16-alpine`. +`ghcr.io/astral-sh/uv:{major}.{minor}-{base}`, e.g., `ghcr.io/astral-sh/uv:0.11.17-alpine`. In addition, starting with `0.8` each derived image also sets `UV_TOOL_BIN_DIR` to `/usr/local/bin` to allow `uv tool install` to work as expected with the default user. @@ -133,7 +133,7 @@ Note this requires `curl` to be available. In either case, it is best practice to pin to a specific uv version, e.g., with: ```dockerfile -COPY --from=ghcr.io/astral-sh/uv:0.11.16 /uv /uvx /bin/ +COPY --from=ghcr.io/astral-sh/uv:0.11.17 /uv /uvx /bin/ ``` !!! tip @@ -151,7 +151,7 @@ COPY --from=ghcr.io/astral-sh/uv:0.11.16 /uv /uvx /bin/ Or, with the installer: ```dockerfile -ADD https://astral.sh/uv/0.11.16/install.sh /uv-installer.sh +ADD https://astral.sh/uv/0.11.17/install.sh /uv-installer.sh ``` ### Installing a project @@ -619,5 +619,5 @@ Verified OK !!! tip These examples use `latest`, but best practice is to verify the attestation for a specific - version tag, e.g., `ghcr.io/astral-sh/uv:0.11.16`, or (even better) the specific image digest, + version tag, e.g., `ghcr.io/astral-sh/uv:0.11.17`, or (even better) the specific image digest, such as `ghcr.io/astral-sh/uv:0.5.27@sha256:5adf09a5a526f380237408032a9308000d14d5947eafa687ad6c6a2476787b4f`. diff --git a/docs/guides/integration/github.md b/docs/guides/integration/github.md index 73ce8b428d0f0..2c3ea6e45c6b5 100644 --- a/docs/guides/integration/github.md +++ b/docs/guides/integration/github.md @@ -47,7 +47,7 @@ jobs: uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: # Install a specific version of uv. - version: "0.11.16" + version: "0.11.17" ``` ## Setting up Python diff --git a/docs/guides/integration/gitlab.md b/docs/guides/integration/gitlab.md index 36842183a9197..52b12145d99a3 100644 --- a/docs/guides/integration/gitlab.md +++ b/docs/guides/integration/gitlab.md @@ -13,7 +13,7 @@ Select a variant that is suitable for your workflow. ```yaml title=".gitlab-ci.yml" variables: - UV_VERSION: "0.11.16" + UV_VERSION: "0.11.17" PYTHON_VERSION: "3.12" BASE_LAYER: trixie-slim # GitLab CI creates a separate mountpoint for the build directory, diff --git a/docs/guides/integration/pre-commit.md b/docs/guides/integration/pre-commit.md index 49f00d0c99081..4cd3a872e8df7 100644 --- a/docs/guides/integration/pre-commit.md +++ b/docs/guides/integration/pre-commit.md @@ -19,7 +19,7 @@ To make sure your `uv.lock` file is up to date even if your `pyproject.toml` fil repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.11.16 + rev: 0.11.17 hooks: - id: uv-lock ``` @@ -30,7 +30,7 @@ To keep a `requirements.txt` file in sync with your `uv.lock` file: repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.11.16 + rev: 0.11.17 hooks: - id: uv-export ``` @@ -41,7 +41,7 @@ To compile requirements files: repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.11.16 + rev: 0.11.17 hooks: # Compile requirements - id: pip-compile @@ -54,7 +54,7 @@ To compile alternative requirements files, modify `args` and `files`: repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.11.16 + rev: 0.11.17 hooks: # Compile requirements - id: pip-compile @@ -68,7 +68,7 @@ To run the hook over multiple files at the same time, add additional entries: repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.11.16 + rev: 0.11.17 hooks: # Compile requirements - id: pip-compile diff --git a/pyproject.toml b/pyproject.toml index 36a52e66388f1..dd5a84746ffa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "uv" -version = "0.11.16" +version = "0.11.17" description = "An extremely fast Python package and project manager, written in Rust." authors = [{ name = "Astral Software Inc.", email = "hey@astral.sh" }] requires-python = ">=3.8" diff --git a/scripts/build-trampolines-in-docker.sh b/scripts/build-trampolines-in-docker.sh new file mode 100755 index 0000000000000..90e41ac7773a5 --- /dev/null +++ b/scripts/build-trampolines-in-docker.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Inner half of build-trampolines.sh: runs inside the uv-trampoline-builder +# container. +# +# Bind-mounts: +# * `/source`: read-only workspace +# * `/output`: directory for the produced .exe files +# Arguments: +# 1. rust nightly toolchain channel + +set -euo pipefail + +TOOLCHAIN="$1" + +export CARGO_TARGET_DIR=/tmp/target + +# Copy workspace to a writable location. +cp -r /source /workspace + +# Normalize all crate versions to 0.0.0 so a uv version bump does not change +# the binaries, as the crate version is part of cargo's Strict Version Hash +# which is embedded in the output. +find /workspace -name Cargo.toml -exec sed -i 's/^version = .*/version = "0.0.0"/' {} + +sed -i -E 's/version = "[^"]+"(, path = ")/version = "0.0.0"\1/g' /workspace/Cargo.toml + +# The working directory must be the trampoline crate so cargo picks up +# `.cargo/config.toml`, which enables build-std. +cd /workspace/crates/uv-trampoline + +cargo +"$TOOLCHAIN" xwin build --xwin-arch x86 --release --target i686-pc-windows-msvc +cargo +"$TOOLCHAIN" xwin build --release --target x86_64-pc-windows-msvc +cargo +"$TOOLCHAIN" xwin build --release --target aarch64-pc-windows-msvc + +for arch in i686 x86_64 aarch64; do + for variant in console gui; do + cp "/tmp/target/$arch-pc-windows-msvc/release/uv-trampoline-$variant.exe" \ + "/output/uv-trampoline-$arch-$variant.exe" + done +done diff --git a/scripts/build-trampolines.sh b/scripts/build-trampolines.sh index 439a326eb5297..03714649b1675 100755 --- a/scripts/build-trampolines.sh +++ b/scripts/build-trampolines.sh @@ -14,7 +14,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" TRAMPOLINE_DIR="$REPO_ROOT/crates/uv-trampoline" OUTPUT_DIR="$REPO_ROOT/crates/uv-trampoline-builder/trampolines" -# Read the nightly toolchain from rust-toolchain.toml +# Read the nightly toolchain from `rust-toolchain.toml`. TOOLCHAIN="$(grep '^channel' "$TRAMPOLINE_DIR/rust-toolchain.toml" | sed 's/.*"\(.*\)"/\1/')" # Pin to linux/amd64 so the container always matches the x86_64-unknown-linux-gnu @@ -27,33 +27,16 @@ docker buildx build -t uv-trampoline-builder --load \ -f "$TRAMPOLINE_DIR/Dockerfile" "$TRAMPOLINE_DIR" \ "$@" -# Build trampolines inside the container with the workspace bind-mounted. -# The working directory must be the trampoline crate so cargo picks up -# .cargo/config.toml (which enables build-std). +# Build trampolines inside the container. docker run --rm \ --platform "$PLATFORM" \ - -v "$REPO_ROOT:/workspace:ro" \ + -v "$REPO_ROOT:/source:ro" \ -v "$OUTPUT_DIR:/output" \ - -w /workspace/crates/uv-trampoline \ + -v "$SCRIPT_DIR/build-trampolines-in-docker.sh:/build-trampolines-in-docker.sh:ro" \ uv-trampoline-builder \ - bash -c ' - set -euo pipefail - export CARGO_TARGET_DIR=/tmp/target - - cargo +"'"$TOOLCHAIN"'" xwin build --xwin-arch x86 --release --target i686-pc-windows-msvc - cargo +"'"$TOOLCHAIN"'" xwin build --release --target x86_64-pc-windows-msvc - cargo +"'"$TOOLCHAIN"'" xwin build --release --target aarch64-pc-windows-msvc - - for arch in i686 x86_64 aarch64; do - for variant in console gui; do - cp /tmp/target/$arch-pc-windows-msvc/release/uv-trampoline-$variant.exe \ - /output/uv-trampoline-$arch-$variant.exe - done - done - ' - -# Zero out non-deterministic PE fields (timestamps, debug GUIDs) so that -# builds are byte-for-byte reproducible despite LLVM non-determinism. + bash /build-trampolines-in-docker.sh "$TOOLCHAIN" + +# Zero out non-deterministic PE fields (timestamps, debug GUIDs). cargo run --quiet -p uv-trampoline-builder --bin normalize-pe-timestamps -- "$OUTPUT_DIR"/*.exe echo "Done. Trampolines written to $OUTPUT_DIR" diff --git a/scripts/generate-known-stdlib.py b/scripts/generate-known-stdlib.py new file mode 100644 index 0000000000000..2f082132135ed --- /dev/null +++ b/scripts/generate-known-stdlib.py @@ -0,0 +1,68 @@ +# /// script +# requires-python = ">=3.12" +# dependencies = ["stdlibs"] +# /// + +"""Generate the normalized standard-library package lookup used by `uv add`.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from stdlibs import stdlib_module_names + +ROOT = Path(__file__).parent.parent +PATH = ROOT / "crates" / "uv-static" / "src" / "known_stdlib.rs" +VERSIONS = range(7, 16) + + +def normalize_package_name(module: str) -> str: + """Normalize a Python module name like a package requirement name.""" + return re.sub(r"[-_.]+", "-", module).lower() + + +def render_match_arm(version: str, modules: set[str], *, first: bool = False) -> str: + alternatives = "\n | ".join( + f'"{module}"' for module in sorted(modules) + ) + prefix = " (\n" if first else " ) | (\n" + return f"{prefix} {version},\n {alternatives}\n" + + +def main() -> None: + modules_by_version: dict[int, set[str]] = {} + for minor_version in VERSIONS: + # Private module names cannot be expressed as normalized package requirements. + modules_by_version[minor_version] = { + normalize_package_name(module) + for module in stdlib_module_names(f"3.{minor_version}") + if module != "__future__" and not module.startswith("_") + } + + ubiquitous_modules = set.intersection(*modules_by_version.values()) + + output = """\ +//! Generated with `uv run scripts/generate-known-stdlib.py`. + +/// Return whether a normalized package name matches a Python standard-library module. +pub fn is_known_standard_library_package(minor_version: u8, package: &str) -> bool { + matches!( + (minor_version, package), +""" + output += render_match_arm("_", ubiquitous_modules, first=True) + for minor_version in VERSIONS: + output += render_match_arm( + str(minor_version), modules_by_version[minor_version] - ubiquitous_modules + ) + output += """\ + ) + ) +} +""" + + PATH.write_text(output) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 97018acd839b2..cb796c79badd3 100644 --- a/uv.lock +++ b/uv.lock @@ -972,7 +972,7 @@ wheels = [ [[package]] name = "uv" -version = "0.11.16" +version = "0.11.17" source = { editable = "." } [package.dev-dependencies]