From 7bfab85177cdbdcc8c07d0cf88915a4a83a71d35 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:43:22 +0900 Subject: [PATCH 01/23] fork: preserve URLs in uv.lock across re-locks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When UV_DEFAULT_INDEX differs across environments (e.g. an internal mirror on developer machines vs. CI, or across regions), upstream `uv lock` rewrites `source.registry`, `sdist.url`, and `wheels[].url` in `uv.lock` to the current mirror's URLs, producing noisy diffs and breaking lockfile portability (astral-sh/uv#6349). This fork adds `Lock::rewrite_urls_from`, invoked after `do_lock` in `LockOperation::execute`, which copies URL fields from the previous lockfile onto the newly-resolved lock whenever a package is still present at the same (name, version). Hashes and versions continue to be written as resolved — only URL fields are held stable. Non-registry sources (git, direct URL, path, directory, editable, virtual) are left untouched. Version bumps naturally pick up fresh URLs because the (name, version) key no longer matches. Also adds a `Fork release` GitHub Actions workflow that publishes a rolling `fork-latest` release with linux x86_64 and macOS aarch64 binaries on every push to main. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/fork-release.yml | 153 +++++++++++ crates/uv-resolver/src/lock/mod.rs | 2 + .../uv-resolver/src/lock/url_preservation.rs | 249 ++++++++++++++++++ crates/uv/src/commands/project/lock.rs | 8 +- 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/fork-release.yml create mode 100644 crates/uv-resolver/src/lock/url_preservation.rs diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml new file mode 100644 index 0000000000000..2622ec302c83b --- /dev/null +++ b/.github/workflows/fork-release.yml @@ -0,0 +1,153 @@ +# fork: publish a rolling release of uv binaries (linux x86_64, macOS aarch64) +# on every push to main. Assets on the `fork-latest` release are overwritten. +name: Fork release + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +env: + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + CARGO_TERM_COLOR: always + RUSTUP_MAX_RETRIES: 10 + RELEASE_TAG: fork-latest + +jobs: + build-linux-x86_64: + name: Build linux x86_64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@v2 + - name: Build uv + uvx + run: cargo build --profile release --bin uv --bin uvx + - name: Package tarball + run: | + mkdir -p dist + tar -czvf dist/uv-x86_64-unknown-linux-gnu.tar.gz \ + -C target/release uv uvx + - uses: actions/upload-artifact@v4 + with: + name: uv-x86_64-unknown-linux-gnu + path: dist/uv-x86_64-unknown-linux-gnu.tar.gz + retention-days: 1 + + build-macos-aarch64: + name: Build macOS aarch64 + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@v2 + - name: Build uv + uvx + run: cargo build --profile release --bin uv --bin uvx + - name: Package tarball + run: | + mkdir -p dist + tar -czvf dist/uv-aarch64-apple-darwin.tar.gz \ + -C target/release uv uvx + - uses: actions/upload-artifact@v4 + with: + name: uv-aarch64-apple-darwin + path: dist/uv-aarch64-apple-darwin.tar.gz + retention-days: 1 + + publish-release: + name: Publish rolling release + needs: [build-linux-x86_64, build-macos-aarch64] + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: Collect metadata + id: meta + run: | + SHA_SHORT="$(git rev-parse --short HEAD)" + echo "sha_short=$SHA_SHORT" >> "$GITHUB_OUTPUT" + # Find the PR associated with this commit (if any). + PR_NUMBER="$(gh pr list --repo "$GITHUB_REPOSITORY" --search "$GITHUB_SHA" --state merged --json number --jq '.[0].number // empty')" + if [ -z "$PR_NUMBER" ]; then + # Fallback: parse "Merge pull request #N" from the commit message. + PR_NUMBER="$(git log -1 --pretty=%s | grep -oE '#[0-9]+' | head -n1 | tr -d '#' || true)" + fi + echo "pr_number=${PR_NUMBER:-}" >> "$GITHUB_OUTPUT" + - name: Write release notes + run: | + cat > RELEASE_NOTES.md <<'EOF' + # uv (mlflow fork) — rolling build + + This release is rebuilt from `main` on every push. Assets are overwritten in place. + + - Commit: `__SHA_SHORT__` (`__SHA__`) + - Latest PR: __PR_LINE__ + - Built: __DATE__ + + ## Fork behavior + + This fork preserves registry, sdist, and wheel URLs in `uv.lock` across re-locks + when the package name and version are unchanged. See + [astral-sh/uv#6349](https://github.com/astral-sh/uv/issues/6349). + + ## Install + + ### Linux (x86_64) + + ```bash + curl -L -o uv.tar.gz \ + https://github.com/__REPO__/releases/download/fork-latest/uv-x86_64-unknown-linux-gnu.tar.gz + tar -xzf uv.tar.gz + install -m 0755 uv uvx "$HOME/.local/bin/" + ``` + + ### macOS (Apple Silicon) + + ```bash + curl -L -o uv.tar.gz \ + https://github.com/__REPO__/releases/download/fork-latest/uv-aarch64-apple-darwin.tar.gz + tar -xzf uv.tar.gz + install -m 0755 uv uvx "$HOME/.local/bin/" + # Clear the quarantine flag added by Safari/curl on downloaded binaries: + xattr -d com.apple.quarantine "$HOME/.local/bin/uv" "$HOME/.local/bin/uvx" 2>/dev/null || true + ``` + + Make sure `$HOME/.local/bin` is on your `PATH`. + EOF + if [ -n "${{ steps.meta.outputs.pr_number }}" ]; then + PR_LINE="#${{ steps.meta.outputs.pr_number }}" + else + PR_LINE="(none found for this commit)" + fi + sed -i "s|__SHA_SHORT__|${{ steps.meta.outputs.sha_short }}|g" RELEASE_NOTES.md + sed -i "s|__SHA__|$GITHUB_SHA|g" RELEASE_NOTES.md + sed -i "s|__PR_LINE__|$PR_LINE|g" RELEASE_NOTES.md + sed -i "s|__REPO__|$GITHUB_REPOSITORY|g" RELEASE_NOTES.md + sed -i "s|__DATE__|$(date -u +'%Y-%m-%dT%H:%M:%SZ')|g" RELEASE_NOTES.md + - name: Upsert rolling release + run: | + TAG="$RELEASE_TAG" + # Delete the existing release (if any) so we can replace assets cleanly. + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag + fi + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$GITHUB_SHA" \ + --title "uv fork (rolling) — ${{ steps.meta.outputs.sha_short }}" \ + --notes-file RELEASE_NOTES.md \ + dist/uv-x86_64-unknown-linux-gnu.tar.gz \ + dist/uv-aarch64-apple-darwin.tar.gz diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 893e75074e946..f37a9a4cb32df 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -72,6 +72,8 @@ mod export; mod installable; mod map; mod tree; +// fork: preserve URLs across re-locks; see issue astral-sh/uv#6349. +mod url_preservation; /// The current version of the lockfile format. pub const VERSION: u32 = 1; diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs new file mode 100644 index 0000000000000..ba05150aac7bf --- /dev/null +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -0,0 +1,249 @@ +//! fork: preserve URLs in `uv.lock` across re-locks when the index URL changes. +//! +//! Upstream `uv lock` bakes the currently-resolved index URL — and file URLs +//! derived from it — into `uv.lock` via [`Source::Registry`], [`SourceDist::Url`], +//! and [`WheelWireSource::Url`]. When `UV_DEFAULT_INDEX` points at an internal +//! mirror that differs across environments (developer machine vs. CI, or across +//! regions), re-running `uv lock` rewrites these URLs, causing noisy diffs in +//! committed lockfiles and breaking portability. +//! +//! This module adds [`Lock::rewrite_urls_from`], which always copies URL fields +//! from a previous lockfile onto the newly-resolved lock when the package is +//! still present at the same (name, version). Hash and version changes are +//! written as-is by the resolver; only the URL fields are normalized back to +//! the previous lock's values. +//! +//! - `source.registry` is copied when the package name+version match and both +//! sides are [`Source::Registry`] with a URL. +//! - `sdist.url` is copied when the package name+version match and both sides +//! use [`SourceDist::Url`]. +//! - Each `wheels[].url` is copied when a previous wheel with the same filename +//! exists and uses [`WheelWireSource::Url`]. +//! +//! A version change yields a (name, version) mismatch, so upgraded packages +//! naturally pick up fresh URLs. Non-registry sources (git, direct URL, path, +//! directory, editable, virtual) are left untouched. + +use rustc_hash::FxHashMap; + +use uv_normalize::PackageName; +use uv_pep440::Version; + +use super::{Lock, Package, RegistrySource, Source, SourceDist, Wheel, WheelWireSource}; + +impl Lock { + /// Preserve URLs from a previous lockfile for packages whose (name, version) + /// are unchanged. See module-level docs for the matching rules. + pub fn rewrite_urls_from(&mut self, previous: &Self) { + let previous_by_key: FxHashMap<(&PackageName, Option<&Version>), &Package> = previous + .packages + .iter() + .map(|package| ((&package.id.name, package.id.version.as_ref()), package)) + .collect(); + + for new_package in &mut self.packages { + let key = (&new_package.id.name, new_package.id.version.as_ref()); + let Some(previous_package) = previous_by_key.get(&key).copied() else { + continue; + }; + + copy_registry_url(&previous_package.id.source, &mut new_package.id.source); + copy_sdist_url(previous_package.sdist.as_ref(), new_package.sdist.as_mut()); + copy_wheel_urls(&previous_package.wheels, &mut new_package.wheels); + } + } +} + +fn copy_registry_url(previous: &Source, new: &mut Source) { + if let ( + Source::Registry(RegistrySource::Url(previous_url)), + Source::Registry(RegistrySource::Url(new_url)), + ) = (previous, new) + { + *new_url = previous_url.clone(); + } +} + +fn copy_sdist_url(previous: Option<&SourceDist>, new: Option<&mut SourceDist>) { + let (Some(previous), Some(new)) = (previous, new) else { + return; + }; + if let ( + SourceDist::Url { + url: previous_url, .. + }, + SourceDist::Url { url: new_url, .. }, + ) = (previous, new) + { + *new_url = previous_url.clone(); + } +} + +fn copy_wheel_urls(previous: &[Wheel], new: &mut [Wheel]) { + for new_wheel in new { + let matching_url = previous.iter().find_map(|previous_wheel| { + if previous_wheel.filename != new_wheel.filename { + return None; + } + if let WheelWireSource::Url { url } = &previous_wheel.url { + Some(url.clone()) + } else { + None + } + }); + let Some(matching_url) = matching_url else { + continue; + }; + if let WheelWireSource::Url { url: new_url } = &mut new_wheel.url { + *new_url = matching_url; + } + } +} + +#[cfg(test)] +mod tests { + use super::super::Lock; + + /// Parses a minimal TOML lockfile for a single iniconfig package, with the + /// given registry URL and file URL prefix. Returns the parsed [`Lock`]. + fn make_lock(registry: &str, file_prefix: &str) -> Lock { + let data = format!( + r#" +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = {{ registry = "{registry}" }} +sdist = {{ url = "{file_prefix}/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }} +wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }}] +"# + ); + toml::from_str(&data).expect("parse lock") + } + + #[test] + fn preserves_urls_on_mirror_change() { + let previous = make_lock( + "https://pypi.org/simple", + "https://files.pythonhosted.org/packages/iniconfig", + ); + let mut new = make_lock( + "https://mirror.example.com/simple", + "https://mirror.example.com/files/iniconfig", + ); + + new.rewrite_urls_from(&previous); + + let rendered = new.to_toml().expect("serialize lock"); + assert!( + rendered.contains(r#"registry = "https://pypi.org/simple""#), + "registry URL should be preserved from previous lock:\n{rendered}" + ); + assert!( + rendered.contains( + "https://files.pythonhosted.org/packages/iniconfig/iniconfig-2.0.0.tar.gz" + ), + "sdist URL should be preserved:\n{rendered}" + ); + assert!( + rendered.contains( + "https://files.pythonhosted.org/packages/iniconfig/iniconfig-2.0.0-py3-none-any.whl" + ), + "wheel URL should be preserved:\n{rendered}" + ); + assert!( + !rendered.contains("mirror.example.com"), + "no mirror URLs should leak into the rewritten lock:\n{rendered}" + ); + } + + #[test] + fn refreshes_urls_on_version_bump() { + let previous = make_lock( + "https://pypi.org/simple", + "https://files.pythonhosted.org/packages/iniconfig", + ); + // new lock is at 2.1.0 — (name, version) key differs → no preservation. + let data = r#" +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://mirror.example.com/simple" } +sdist = { url = "https://mirror.example.com/files/iniconfig/iniconfig-2.1.0.tar.gz", hash = "sha256:0000000000000000000000000000000000000000000000000000000000000001", size = 4646 } +wheels = [{ url = "https://mirror.example.com/files/iniconfig/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:0000000000000000000000000000000000000000000000000000000000000002", size = 5892 }] +"#; + let mut new: Lock = toml::from_str(data).expect("parse lock"); + + new.rewrite_urls_from(&previous); + + let rendered = new.to_toml().expect("serialize lock"); + assert!( + rendered.contains(r#"registry = "https://mirror.example.com/simple""#), + "registry URL should not be preserved across version bump:\n{rendered}" + ); + } + + #[test] + fn preserves_urls_but_keeps_new_hash_when_hash_differs() { + let previous = make_lock( + "https://pypi.org/simple", + "https://files.pythonhosted.org/packages/iniconfig", + ); + // Same (name, version) but different hashes — URLs preserved, hashes kept as-is. + let data = r#" +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://mirror.example.com/simple" } +sdist = { url = "https://mirror.example.com/files/iniconfig/iniconfig-2.0.0.tar.gz", hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", size = 4646 } +wheels = [{ url = "https://mirror.example.com/files/iniconfig/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", size = 5892 }] +"#; + let mut new: Lock = toml::from_str(data).expect("parse lock"); + + new.rewrite_urls_from(&previous); + + let rendered = new.to_toml().expect("serialize lock"); + assert!( + !rendered.contains("mirror.example.com"), + "mirror URLs should not leak:\n{rendered}" + ); + // The new hashes are preserved from the new resolution. + assert!( + rendered.contains("aaaaaaaaaaaa"), + "new sdist hash should be kept:\n{rendered}" + ); + assert!( + rendered.contains("bbbbbbbbbbbb"), + "new wheel hash should be kept:\n{rendered}" + ); + } + + #[test] + fn no_previous_match_leaves_urls_untouched() { + let previous_data = r#" +version = 1 +requires-python = ">=3.12" +"#; + let previous: Lock = toml::from_str(previous_data).expect("parse lock"); + let mut new = make_lock( + "https://mirror.example.com/simple", + "https://mirror.example.com/files/iniconfig", + ); + + new.rewrite_urls_from(&previous); + + let rendered = new.to_toml().expect("serialize lock"); + assert!( + rendered.contains(r#"registry = "https://mirror.example.com/simple""#), + "registry URL should be untouched when previous has no match:\n{rendered}" + ); + } +} diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 344b072d5b81e..bf0e9fa2917a9 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -434,7 +434,8 @@ impl<'env> LockOperation<'env> { }; // Perform the lock operation. - let result = Box::pin(do_lock( + // fork: was `let result` — now mutable for URL preservation below. + let mut result = Box::pin(do_lock( target, interpreter, existing, @@ -452,6 +453,11 @@ impl<'env> LockOperation<'env> { )) .await?; + // fork: preserve URLs across re-locks; see issue astral-sh/uv#6349. + if let LockResult::Changed(Some(previous), lock) = &mut result { + lock.rewrite_urls_from(previous); + } + // If the lockfile changed, write it to disk. if !matches!(self.mode, LockMode::DryRun(_)) { if let LockResult::Changed(_, lock) = &result { From 9a09fc9ff54f6f15107231bc02599fc888055cc7 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:49:12 +0900 Subject: [PATCH 02/23] fork-release: trigger per-PR, tag releases pr- Workflow now triggers on pull_request (opened/synchronize/reopened) and publishes a release tagged `pr-` per PR, so each standing fork PR gets its own binaries without overwriting other PRs' assets. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/fork-release.yml | 91 +++++++++++++----------------- 1 file changed, 38 insertions(+), 53 deletions(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 2622ec302c83b..0a2bb215fd828 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -1,11 +1,10 @@ -# fork: publish a rolling release of uv binaries (linux x86_64, macOS aarch64) -# on every push to main. Assets on the `fork-latest` release are overwritten. +# fork: publish per-PR uv binaries (linux x86_64, macOS aarch64). +# Each PR has one release tagged `pr-`; assets overwrite on each push. name: Fork release on: - push: - branches: [main] - workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened] permissions: contents: write @@ -15,7 +14,6 @@ env: CARGO_NET_RETRY: 10 CARGO_TERM_COLOR: always RUSTUP_MAX_RETRIES: 10 - RELEASE_TAG: fork-latest jobs: build-linux-x86_64: @@ -61,11 +59,14 @@ jobs: retention-days: 1 publish-release: - name: Publish rolling release + name: Publish PR release needs: [build-linux-x86_64, build-macos-aarch64] runs-on: ubuntu-latest env: GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_TITLE: ${{ github.event.pull_request.title }} steps: - uses: actions/checkout@v4 with: @@ -74,32 +75,26 @@ jobs: with: path: dist merge-multiple: true - - name: Collect metadata - id: meta - run: | - SHA_SHORT="$(git rev-parse --short HEAD)" - echo "sha_short=$SHA_SHORT" >> "$GITHUB_OUTPUT" - # Find the PR associated with this commit (if any). - PR_NUMBER="$(gh pr list --repo "$GITHUB_REPOSITORY" --search "$GITHUB_SHA" --state merged --json number --jq '.[0].number // empty')" - if [ -z "$PR_NUMBER" ]; then - # Fallback: parse "Merge pull request #N" from the commit message. - PR_NUMBER="$(git log -1 --pretty=%s | grep -oE '#[0-9]+' | head -n1 | tr -d '#' || true)" - fi - echo "pr_number=${PR_NUMBER:-}" >> "$GITHUB_OUTPUT" - name: Write release notes + id: notes run: | - cat > RELEASE_NOTES.md <<'EOF' - # uv (mlflow fork) — rolling build + TAG="pr-${PR_NUMBER}" + SHA_SHORT="$(echo "$PR_HEAD_SHA" | cut -c1-7)" + DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "sha_short=$SHA_SHORT" >> "$GITHUB_OUTPUT" + cat > RELEASE_NOTES.md </dev/null || true - ``` + xattr -d com.apple.quarantine "\$HOME/.local/bin/uv" "\$HOME/.local/bin/uvx" 2>/dev/null || true + \`\`\` - Make sure `$HOME/.local/bin` is on your `PATH`. + Make sure \`\$HOME/.local/bin\` is on your \`PATH\`. EOF - if [ -n "${{ steps.meta.outputs.pr_number }}" ]; then - PR_LINE="#${{ steps.meta.outputs.pr_number }}" - else - PR_LINE="(none found for this commit)" - fi - sed -i "s|__SHA_SHORT__|${{ steps.meta.outputs.sha_short }}|g" RELEASE_NOTES.md - sed -i "s|__SHA__|$GITHUB_SHA|g" RELEASE_NOTES.md - sed -i "s|__PR_LINE__|$PR_LINE|g" RELEASE_NOTES.md - sed -i "s|__REPO__|$GITHUB_REPOSITORY|g" RELEASE_NOTES.md - sed -i "s|__DATE__|$(date -u +'%Y-%m-%dT%H:%M:%SZ')|g" RELEASE_NOTES.md - - name: Upsert rolling release + - name: Upsert PR release run: | - TAG="$RELEASE_TAG" - # Delete the existing release (if any) so we can replace assets cleanly. + TAG="${{ steps.notes.outputs.tag }}" + SHA_SHORT="${{ steps.notes.outputs.sha_short }}" if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag fi gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ - --target "$GITHUB_SHA" \ - --title "uv fork (rolling) — ${{ steps.meta.outputs.sha_short }}" \ + --target "$PR_HEAD_SHA" \ + --title "uv — PR #${PR_NUMBER} (${SHA_SHORT})" \ --notes-file RELEASE_NOTES.md \ dist/uv-x86_64-unknown-linux-gnu.tar.gz \ dist/uv-aarch64-apple-darwin.tar.gz From a1290138e0b3229fa1a84f94b1e7e2dd5cdaf5e0 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:49:29 +0900 Subject: [PATCH 03/23] fork-release: use pr- as release title Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/fork-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 0a2bb215fd828..d165cee35ffcb 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -132,7 +132,7 @@ jobs: gh release create "$TAG" \ --repo "$GITHUB_REPOSITORY" \ --target "$PR_HEAD_SHA" \ - --title "uv — PR #${PR_NUMBER} (${SHA_SHORT})" \ + --title "$TAG" \ --notes-file RELEASE_NOTES.md \ dist/uv-x86_64-unknown-linux-gnu.tar.gz \ dist/uv-aarch64-apple-darwin.tar.gz From e81972f2f44883ceb413691daee2b2db55345335 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:50:31 +0900 Subject: [PATCH 04/23] fork-release: force-move tag in place instead of delete+recreate Previously each run deleted the existing release (and its tag) before recreating it, which briefly left no release present. Now the tag ref is force-updated via the GitHub API, release notes are edited in place, and assets are replaced with `gh release upload --clobber`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/fork-release.yml | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index d165cee35ffcb..84ab42ecf6bae 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -125,14 +125,23 @@ jobs: - name: Upsert PR release run: | TAG="${{ steps.notes.outputs.tag }}" - SHA_SHORT="${{ steps.notes.outputs.sha_short }}" if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag + # Force-move the existing tag to the new PR head SHA, then refresh + # notes and assets in place. + gh api --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$TAG" \ + -f sha="$PR_HEAD_SHA" -F force=true >/dev/null + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" \ + --title "$TAG" \ + --notes-file RELEASE_NOTES.md + gh release upload "$TAG" --repo "$GITHUB_REPOSITORY" --clobber \ + dist/uv-x86_64-unknown-linux-gnu.tar.gz \ + dist/uv-aarch64-apple-darwin.tar.gz + else + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$PR_HEAD_SHA" \ + --title "$TAG" \ + --notes-file RELEASE_NOTES.md \ + dist/uv-x86_64-unknown-linux-gnu.tar.gz \ + dist/uv-aarch64-apple-darwin.tar.gz fi - gh release create "$TAG" \ - --repo "$GITHUB_REPOSITORY" \ - --target "$PR_HEAD_SHA" \ - --title "$TAG" \ - --notes-file RELEASE_NOTES.md \ - dist/uv-x86_64-unknown-linux-gnu.tar.gz \ - dist/uv-aarch64-apple-darwin.tar.gz From d8fdd1f5a421d8fc884bcca6ba5dc2587e607ae2 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:51:35 +0900 Subject: [PATCH 05/23] fork-release: SHA-pin actions and add concurrency group Pin all actions to the same SHAs upstream uv uses: - actions/checkout v6.0.2 - Swatinem/rust-cache v2.9.1 - actions/upload-artifact v7.0.1 - actions/download-artifact v8.0.1 Add a per-PR concurrency group so a new push to a PR cancels any in-progress run for the same PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/fork-release.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 84ab42ecf6bae..69cb7611bdc11 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -6,6 +6,10 @@ on: pull_request: types: [opened, synchronize, reopened] +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: write @@ -20,10 +24,10 @@ jobs: name: Build linux x86_64 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Build uv + uvx run: cargo build --profile release --bin uv --bin uvx - name: Package tarball @@ -31,7 +35,7 @@ jobs: mkdir -p dist tar -czvf dist/uv-x86_64-unknown-linux-gnu.tar.gz \ -C target/release uv uvx - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: uv-x86_64-unknown-linux-gnu path: dist/uv-x86_64-unknown-linux-gnu.tar.gz @@ -41,10 +45,10 @@ jobs: name: Build macOS aarch64 runs-on: macos-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Build uv + uvx run: cargo build --profile release --bin uv --bin uvx - name: Package tarball @@ -52,7 +56,7 @@ jobs: mkdir -p dist tar -czvf dist/uv-aarch64-apple-darwin.tar.gz \ -C target/release uv uvx - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: uv-aarch64-apple-darwin path: dist/uv-aarch64-apple-darwin.tar.gz @@ -71,7 +75,7 @@ jobs: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: dist merge-multiple: true From 8edf7344114db1b7cb521481d1281b04f2b67605 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:23:13 +0900 Subject: [PATCH 06/23] fork: skip uv.lock write when URL preservation makes it a no-op When the only difference between the resolver's new lock and the previous on-disk lock is the index URL (and derived file URLs), `rewrite_urls_from` produces a new lock equal to the previous one. Before this change we still wrote that byte-identical content back to disk, touching the file's mtime and bothering build tools that watch for modifications. Now we compare the post-rewrite lock against `previous` and skip `target.commit(...)` when they match. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/uv/src/commands/project/lock.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index bf0e9fa2917a9..4edd0a3f7cf0d 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -454,12 +454,18 @@ impl<'env> LockOperation<'env> { .await?; // fork: preserve URLs across re-locks; see issue astral-sh/uv#6349. + // If URL preservation makes the new lock equal to the previous one, + // skip the write so we don't touch uv.lock's mtime. + let mut skip_commit = false; if let LockResult::Changed(Some(previous), lock) = &mut result { lock.rewrite_urls_from(previous); + if lock == previous { + skip_commit = true; + } } // If the lockfile changed, write it to disk. - if !matches!(self.mode, LockMode::DryRun(_)) { + if !matches!(self.mode, LockMode::DryRun(_)) && !skip_commit { if let LockResult::Changed(_, lock) = &result { target.commit(lock).await?; } From a2489ae9a4eb265c013b519b21a23fd454f9b256 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:30:36 +0900 Subject: [PATCH 07/23] fork: rewrite PackageId in every Dependency too, not just Package.id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PackageId` implements Hash+Eq over its `source` field. Mutating `Package.id.source` without touching `Dependency.package_id.source` left the same logical package keyed under two different hashes, causing a panic in `installable.rs:508` when `inverse[&package.id]` could not find the entry that had been inserted under the dependency's hash. Fix by applying the preserved-registry URL substitution to every `PackageId` occurrence in the lock — `Package.id` plus every `Dependency.package_id` inside `dependencies`, `optional_dependencies`, and `dependency_groups`. Added a two-package dependency-edge test that verifies all `Dependency.package_id.source` values are rewritten and that the re-serialized lock is byte-identical to the previous one. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../uv-resolver/src/lock/url_preservation.rs | 177 ++++++++++++++---- 1 file changed, 144 insertions(+), 33 deletions(-) diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index ba05150aac7bf..035bc9980000f 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -3,64 +3,103 @@ //! Upstream `uv lock` bakes the currently-resolved index URL — and file URLs //! derived from it — into `uv.lock` via [`Source::Registry`], [`SourceDist::Url`], //! and [`WheelWireSource::Url`]. When `UV_DEFAULT_INDEX` points at an internal -//! mirror that differs across environments (developer machine vs. CI, or across -//! regions), re-running `uv lock` rewrites these URLs, causing noisy diffs in -//! committed lockfiles and breaking portability. +//! mirror that differs across environments, re-running `uv lock` rewrites these +//! URLs, causing noisy diffs and breaking portability. //! -//! This module adds [`Lock::rewrite_urls_from`], which always copies URL fields -//! from a previous lockfile onto the newly-resolved lock when the package is -//! still present at the same (name, version). Hash and version changes are -//! written as-is by the resolver; only the URL fields are normalized back to -//! the previous lock's values. +//! This module adds [`Lock::rewrite_urls_from`], which copies URL fields from a +//! previous lockfile onto the newly-resolved lock when the package is still +//! present at the same (name, version). Hashes and versions continue to be +//! written as resolved; only URL fields are held stable. //! -//! - `source.registry` is copied when the package name+version match and both -//! sides are [`Source::Registry`] with a URL. -//! - `sdist.url` is copied when the package name+version match and both sides -//! use [`SourceDist::Url`]. -//! - Each `wheels[].url` is copied when a previous wheel with the same filename -//! exists and uses [`WheelWireSource::Url`]. +//! Implementation notes: [`PackageId`] appears in two places inside the lock — +//! on each [`Package`] via `Package.id`, and on every [`Dependency`] via +//! `Dependency.package_id`. `PackageId` implements `Hash + Eq` over its `source` +//! field, so mutating the registry URL on `Package.id` without mutating the +//! matching `Dependency.package_id` breaks any `HashMap<&PackageId, _>` lookup +//! downstream (e.g. the dependency-graph build in `installable.rs`). To keep +//! the lock internally consistent we apply the URL substitution to every +//! `PackageId` occurrence. //! -//! A version change yields a (name, version) mismatch, so upgraded packages -//! naturally pick up fresh URLs. Non-registry sources (git, direct URL, path, -//! directory, editable, virtual) are left untouched. +//! Non-registry sources (git, direct URL, path, directory, editable, virtual) +//! are left untouched. use rustc_hash::FxHashMap; +use uv_distribution_types::UrlString; use uv_normalize::PackageName; use uv_pep440::Version; -use super::{Lock, Package, RegistrySource, Source, SourceDist, Wheel, WheelWireSource}; +use super::{ + Lock, Package, PackageId, RegistrySource, Source, SourceDist, Wheel, WheelWireSource, +}; impl Lock { /// Preserve URLs from a previous lockfile for packages whose (name, version) /// are unchanged. See module-level docs for the matching rules. pub fn rewrite_urls_from(&mut self, previous: &Self) { + // Build a map of (name, version) → preserved registry URL from the + // previous lock. These are the URLs we want every PackageId with the + // same (name, version) to use after rewriting. + let preserved_registry: FxHashMap<(&PackageName, Option<&Version>), &UrlString> = previous + .packages + .iter() + .filter_map(|package| { + let Source::Registry(RegistrySource::Url(url)) = &package.id.source else { + return None; + }; + Some(((&package.id.name, package.id.version.as_ref()), url)) + }) + .collect(); + + // Apply the registry URL mapping to every PackageId in the lock. This + // covers both `Package.id` and every `Dependency.package_id` nested + // inside a package's dependencies, optional dependencies, and + // dependency groups — all must stay in sync so downstream HashMap + // lookups (e.g. in `installable.rs`) still work. + for package in &mut self.packages { + apply_preserved_registry(&mut package.id, &preserved_registry); + for dep in &mut package.dependencies { + apply_preserved_registry(&mut dep.package_id, &preserved_registry); + } + for deps in package.optional_dependencies.values_mut() { + for dep in deps { + apply_preserved_registry(&mut dep.package_id, &preserved_registry); + } + } + for deps in package.dependency_groups.values_mut() { + for dep in deps { + apply_preserved_registry(&mut dep.package_id, &preserved_registry); + } + } + } + + // Preserve sdist / wheel URLs per-package. let previous_by_key: FxHashMap<(&PackageName, Option<&Version>), &Package> = previous .packages .iter() .map(|package| ((&package.id.name, package.id.version.as_ref()), package)) .collect(); - for new_package in &mut self.packages { let key = (&new_package.id.name, new_package.id.version.as_ref()); let Some(previous_package) = previous_by_key.get(&key).copied() else { continue; }; - - copy_registry_url(&previous_package.id.source, &mut new_package.id.source); copy_sdist_url(previous_package.sdist.as_ref(), new_package.sdist.as_mut()); copy_wheel_urls(&previous_package.wheels, &mut new_package.wheels); } } } -fn copy_registry_url(previous: &Source, new: &mut Source) { - if let ( - Source::Registry(RegistrySource::Url(previous_url)), - Source::Registry(RegistrySource::Url(new_url)), - ) = (previous, new) - { - *new_url = previous_url.clone(); +fn apply_preserved_registry( + id: &mut PackageId, + preserved: &FxHashMap<(&PackageName, Option<&Version>), &UrlString>, +) { + let Source::Registry(RegistrySource::Url(url)) = &mut id.source else { + return; + }; + let key = (&id.name, id.version.as_ref()); + if let Some(preserved_url) = preserved.get(&key) { + *url = (*preserved_url).clone(); } } @@ -104,14 +143,43 @@ fn copy_wheel_urls(previous: &[Wheel], new: &mut [Wheel]) { mod tests { use super::super::Lock; - /// Parses a minimal TOML lockfile for a single iniconfig package, with the - /// given registry URL and file URL prefix. Returns the parsed [`Lock`]. fn make_lock(registry: &str, file_prefix: &str) -> Lock { let data = format!( r#" version = 1 requires-python = ">=3.12" +[[package]] +name = "iniconfig" +version = "2.0.0" +source = {{ registry = "{registry}" }} +sdist = {{ url = "{file_prefix}/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }} +wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }}] +"# + ); + toml::from_str(&data).expect("parse lock") + } + + /// Builds a two-package lockfile (`anyio` depending on `iniconfig`) at the + /// given registry/file prefix. Both packages use the same registry URL. + fn make_lock_with_dependency(registry: &str, file_prefix: &str) -> Lock { + let data = format!( + r#" +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.3.0" +source = {{ registry = "{registry}" }} +sdist = {{ url = "{file_prefix}/anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6", size = 159642 }} +wheels = [{{ url = "{file_prefix}/anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8", size = 85584 }}] + +[[package.dependencies]] +name = "iniconfig" +version = "2.0.0" +source = {{ registry = "{registry}" }} + [[package]] name = "iniconfig" version = "2.0.0" @@ -159,13 +227,58 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha ); } + #[test] + fn rewrites_dependency_package_ids() { + // Two-package lock with a dependency edge — guards against the panic + // in installable.rs:508 where `inverse[&package.id]` fails when + // `Package.id` and `Dependency.package_id` drift out of sync. + let previous = make_lock_with_dependency( + "https://pypi.org/simple", + "https://files.pythonhosted.org/packages", + ); + let mut new = make_lock_with_dependency( + "https://mirror.example.com/simple", + "https://mirror.example.com/files", + ); + + new.rewrite_urls_from(&previous); + + // Every `Dependency.package_id.source` must be updated in lockstep with + // `Package.id.source`; otherwise downstream HashMap lookups break. We + // verify this structurally: walk every dependency's PackageId and make + // sure no mirror URL survives. + for package in &new.packages { + for dep in &package.dependencies { + if let super::Source::Registry(super::RegistrySource::Url(url)) = + &dep.package_id.source + { + assert!( + !url.to_string().contains("mirror.example.com"), + "dependency {} still references mirror URL {url:?}", + dep.package_id.name + ); + } + } + } + + // The re-serialized new lock must be byte-identical to the previous one + // (same resolution, URLs all preserved) — this is the invariant the + // skip-commit optimization relies on. + let new_rendered = new.to_toml().expect("serialize new lock"); + let previous_rendered = previous.to_toml().expect("serialize previous lock"); + assert_eq!( + new_rendered, previous_rendered, + "rewritten lock should match previous lock byte-for-byte\n\ + --- new ---\n{new_rendered}\n--- previous ---\n{previous_rendered}" + ); + } + #[test] fn refreshes_urls_on_version_bump() { let previous = make_lock( "https://pypi.org/simple", "https://files.pythonhosted.org/packages/iniconfig", ); - // new lock is at 2.1.0 — (name, version) key differs → no preservation. let data = r#" version = 1 requires-python = ">=3.12" @@ -194,7 +307,6 @@ wheels = [{ url = "https://mirror.example.com/files/iniconfig/iniconfig-2.1.0-py "https://pypi.org/simple", "https://files.pythonhosted.org/packages/iniconfig", ); - // Same (name, version) but different hashes — URLs preserved, hashes kept as-is. let data = r#" version = 1 requires-python = ">=3.12" @@ -215,7 +327,6 @@ wheels = [{ url = "https://mirror.example.com/files/iniconfig/iniconfig-2.0.0-py !rendered.contains("mirror.example.com"), "mirror URLs should not leak:\n{rendered}" ); - // The new hashes are preserved from the new resolution. assert!( rendered.contains("aaaaaaaaaaaa"), "new sdist hash should be kept:\n{rendered}" From 219f296931cd3d946910a7f5f8c24a1c893680cc Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:34:07 +0900 Subject: [PATCH 08/23] fork: apply rustfmt to url_preservation.rs Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/uv-resolver/src/lock/url_preservation.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index 035bc9980000f..29d8f9c5f7f47 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -29,9 +29,7 @@ use uv_distribution_types::UrlString; use uv_normalize::PackageName; use uv_pep440::Version; -use super::{ - Lock, Package, PackageId, RegistrySource, Source, SourceDist, Wheel, WheelWireSource, -}; +use super::{Lock, Package, PackageId, RegistrySource, Source, SourceDist, Wheel, WheelWireSource}; impl Lock { /// Preserve URLs from a previous lockfile for packages whose (name, version) From af32c90d0a894edca60b8c4acad909923bea5e33 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:00:16 +0900 Subject: [PATCH 09/23] fork: rebuild Lock.by_id after URL rewrite; clean up install tmp dir `Lock.by_id` is a cached `FxHashMap` built once in `Lock::new`. After `rewrite_urls_from` mutates `Package.id.source`, the cached keys become stale and `find_by_id` panics at `lock/mod.rs:1464` with "locked package for ID" during sync/graph construction (for example via `installable.rs:2223`). Rebuild `by_id` at the end of `rewrite_urls_from` to reflect the post-mutation PackageIds. Add a regression test that runs `find_by_id` on every dependency after a rewrite. Also update the release-notes install snippets to use `mktemp -d` + `trap` cleanup so the downloaded tarball and extracted binaries are removed after installation. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/fork-release.yml | 14 ++++++----- .../uv-resolver/src/lock/url_preservation.rs | 23 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 69cb7611bdc11..fc751afa774a2 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -107,19 +107,21 @@ jobs: ### Linux (x86_64) \`\`\`bash - curl -L -o uv.tar.gz \\ + tmpdir="\$(mktemp -d)" && trap "rm -rf \"\$tmpdir\"" EXIT + curl -L -o "\$tmpdir/uv.tar.gz" \\ ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}/uv-x86_64-unknown-linux-gnu.tar.gz - tar -xzf uv.tar.gz - install -m 0755 uv uvx "\$HOME/.local/bin/" + tar -xzf "\$tmpdir/uv.tar.gz" -C "\$tmpdir" + install -m 0755 "\$tmpdir/uv" "\$tmpdir/uvx" "\$HOME/.local/bin/" \`\`\` ### macOS (Apple Silicon) \`\`\`bash - curl -L -o uv.tar.gz \\ + tmpdir="\$(mktemp -d)" && trap "rm -rf \"\$tmpdir\"" EXIT + curl -L -o "\$tmpdir/uv.tar.gz" \\ ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}/uv-aarch64-apple-darwin.tar.gz - tar -xzf uv.tar.gz - install -m 0755 uv uvx "\$HOME/.local/bin/" + tar -xzf "\$tmpdir/uv.tar.gz" -C "\$tmpdir" + install -m 0755 "\$tmpdir/uv" "\$tmpdir/uvx" "\$HOME/.local/bin/" # Clear the quarantine flag added by Safari/curl on downloaded binaries: xattr -d com.apple.quarantine "\$HOME/.local/bin/uv" "\$HOME/.local/bin/uvx" 2>/dev/null || true \`\`\` diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index 29d8f9c5f7f47..65c4d5264e9df 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -85,6 +85,14 @@ impl Lock { copy_sdist_url(previous_package.sdist.as_ref(), new_package.sdist.as_mut()); copy_wheel_urls(&previous_package.wheels, &mut new_package.wheels); } + + // `Lock.by_id` is a cached `PackageId → index` map built once during + // `Lock::new`. Since we just mutated `Package.id.source` on some entries, + // the cached keys are stale and `find_by_id` would panic. Rebuild it. + self.by_id.clear(); + for (index, package) in self.packages.iter().enumerate() { + self.by_id.insert(package.id.clone(), index); + } } } @@ -269,6 +277,21 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha "rewritten lock should match previous lock byte-for-byte\n\ --- new ---\n{new_rendered}\n--- previous ---\n{previous_rendered}" ); + + // `find_by_id` relies on the cached `Lock.by_id` map; if we forgot to + // rebuild it after mutating `Package.id.source` the lookup would panic + // (as `installable.rs:1464` did in practice). Walk every dependency + // and resolve its package to ensure no stale keys remain. + for package in &new.packages { + for dep in &package.dependencies { + let resolved = new.find_by_id(&dep.package_id); + assert_eq!( + resolved.id.name, dep.package_id.name, + "find_by_id returned wrong package for dependency {}", + dep.package_id.name + ); + } + } } #[test] From ae673e7b83e9b1e7c3b3c84f8c359d7b11e26ea0 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:54:34 +0900 Subject: [PATCH 10/23] fork: skip remote-index URL check in satisfies() for URL preservation URL preservation keeps the previous registry URL in the lockfile, which won't match the current UV_DEFAULT_INDEX when mirrors differ. The satisfies() check was returning MissingRemoteIndex on every run, forcing a full re-resolve even when nothing changed. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-resolver/src/lock/mod.rs | 122 ++++++++++++++++------------- 1 file changed, 66 insertions(+), 56 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index f37a9a4cb32df..4d347bd91d180 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1826,18 +1826,21 @@ impl Lock { } // Collect the set of available indexes (both `--index-url` and `--find-links` entries). - let mut remotes = indexes.map(|locations| { - locations - .allowed_indexes() - .into_iter() - .filter_map(|index| match index.url() { - IndexUrl::Pypi(_) | IndexUrl::Url(_) => { - Some(UrlString::from(index.url().without_credentials().as_ref())) - } - IndexUrl::Path(_) => None, - }) - .collect::>() - }); + // fork: remote index collection commented out — URL preservation means the + // lockfile intentionally keeps previous registry URLs that may not match the + // current index configuration. + // let mut remotes = indexes.map(|locations| { + // locations + // .allowed_indexes() + // .into_iter() + // .filter_map(|index| match index.url() { + // IndexUrl::Pypi(_) | IndexUrl::Url(_) => { + // Some(UrlString::from(index.url().without_credentials().as_ref())) + // } + // IndexUrl::Path(_) => None, + // }) + // .collect::>() + // }); let mut locals = indexes.map(|locations| { locations @@ -1884,21 +1887,23 @@ impl Lock { index: Some(index), .. } = &requirement.source { - match &index.url { - IndexUrl::Pypi(_) | IndexUrl::Url(_) => { - if let Some(remotes) = remotes.as_mut() { - remotes.insert(UrlString::from( - index.url().without_credentials().as_ref(), - )); - } - } - IndexUrl::Path(url) => { - if let Some(locals) = locals.as_mut() { - if let Some(path) = url.to_file_path().ok().and_then(|path| { - try_relative_to_if(&path, root, !url.was_given_absolute()).ok() - }) { - locals.insert(path.into_boxed_path()); - } + // fork: remote index insertion commented out — see note above. + // match &index.url { + // IndexUrl::Pypi(_) | IndexUrl::Url(_) => { + // if let Some(remotes) = remotes.as_mut() { + // remotes.insert(UrlString::from( + // index.url().without_credentials().as_ref(), + // )); + // } + // } + // IndexUrl::Path(url) => { ... } + // } + if let IndexUrl::Path(url) = &index.url { + if let Some(locals) = locals.as_mut() { + if let Some(path) = url.to_file_path().ok().and_then(|path| { + try_relative_to_if(&path, root, !url.was_given_absolute()).ok() + }) { + locals.insert(path.into_boxed_path()); } } } @@ -1955,20 +1960,14 @@ impl Lock { // If the lockfile references an index that was not provided, we can't validate it. if let Source::Registry(index) = &package.id.source { match index { - RegistrySource::Url(url) => { - if remotes - .as_ref() - .is_some_and(|remotes| !remotes.contains(url)) - { - let name = &package.id.name; - let version = &package - .id - .version - .as_ref() - .expect("version for registry source"); - return Ok(SatisfiesResult::MissingRemoteIndex(name, version, url)); - } - } + // fork: skip the remote-index URL check. URL preservation + // (`rewrite_urls_from`) intentionally keeps the *previous* + // registry URL in the lockfile, which will not match the + // current `UV_DEFAULT_INDEX` when the user switches mirrors. + // Without this, `satisfies` returns `MissingRemoteIndex` on + // every run, defeating the lock cache and forcing a full + // re-resolve. + RegistrySource::Url(_) => {} RegistrySource::Path(path) => { if locals.as_ref().is_some_and(|locals| !locals.contains(path)) { let name = &package.id.name; @@ -2258,21 +2257,32 @@ impl Lock { index: Some(index), .. } = &requirement.source { - match &index.url { - IndexUrl::Pypi(_) | IndexUrl::Url(_) => { - if let Some(remotes) = remotes.as_mut() { - remotes.insert(UrlString::from( - index.url().without_credentials().as_ref(), - )); - } - } - IndexUrl::Path(url) => { - if let Some(locals) = locals.as_mut() { - if let Some(path) = url.to_file_path().ok().and_then(|path| { - try_relative_to_if(&path, root, !url.was_given_absolute()).ok() - }) { - locals.insert(path.into_boxed_path()); - } + // fork: remote index insertion commented out — URL preservation + // keeps previous registry URLs, so we don't need to track remotes. + // match &index.url { + // IndexUrl::Pypi(_) | IndexUrl::Url(_) => { + // if let Some(remotes) = remotes.as_mut() { + // remotes.insert(UrlString::from( + // index.url().without_credentials().as_ref(), + // )); + // } + // } + // IndexUrl::Path(url) => { + // if let Some(locals) = locals.as_mut() { + // if let Some(path) = url.to_file_path().ok().and_then(|path| { + // try_relative_to_if(&path, root, !url.was_given_absolute()).ok() + // }) { + // locals.insert(path.into_boxed_path()); + // } + // } + // } + // } + if let IndexUrl::Path(url) = &index.url { + if let Some(locals) = locals.as_mut() { + if let Some(path) = url.to_file_path().ok().and_then(|path| { + try_relative_to_if(&path, root, !url.was_given_absolute()).ok() + }) { + locals.insert(path.into_boxed_path()); } } } From 413bd1f2833a1d6d86f47c7046db3c2c1968d633 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:56:14 +0900 Subject: [PATCH 11/23] fork: normalize newly added packages to canonical registry URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `uv add` introduces a new package, it gets the current UV_DEFAULT_INDEX mirror URL as its registry source. Previously, rewrite_urls_from only preserved URLs for packages matching by (name, version) — new packages kept the mirror URL. Now infer the canonical registry URL from the previous lockfile and apply it to all registry packages, including newly added ones, so the lockfile stays consistent regardless of which mirror resolved the package. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../uv-resolver/src/lock/url_preservation.rs | 94 ++++++++++++++++++- 1 file changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index 65c4d5264e9df..999be4e050ac5 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -34,7 +34,23 @@ use super::{Lock, Package, PackageId, RegistrySource, Source, SourceDist, Wheel, impl Lock { /// Preserve URLs from a previous lockfile for packages whose (name, version) /// are unchanged. See module-level docs for the matching rules. + /// + /// For newly added packages (not present in `previous`), the canonical + /// registry URL is inferred from the previous lockfile and applied so that + /// all registry packages use a consistent URL, even when the current + /// `UV_DEFAULT_INDEX` points at a different mirror. pub fn rewrite_urls_from(&mut self, previous: &Self) { + // Determine the canonical registry URL from the previous lock. We pick + // the first registry URL we find — after URL preservation has been + // running, all packages share the same registry URL. + let canonical_registry: Option<&UrlString> = previous.packages.iter().find_map(|package| { + if let Source::Registry(RegistrySource::Url(url)) = &package.id.source { + Some(url) + } else { + None + } + }); + // Build a map of (name, version) → preserved registry URL from the // previous lock. These are the URLs we want every PackageId with the // same (name, version) to use after rewriting. @@ -54,19 +70,35 @@ impl Lock { // inside a package's dependencies, optional dependencies, and // dependency groups — all must stay in sync so downstream HashMap // lookups (e.g. in `installable.rs`) still work. + // + // For packages not found in `preserved_registry`, fall back to the + // canonical registry URL so newly added packages also get the + // consistent URL. for package in &mut self.packages { - apply_preserved_registry(&mut package.id, &preserved_registry); + apply_preserved_registry(&mut package.id, &preserved_registry, canonical_registry); for dep in &mut package.dependencies { - apply_preserved_registry(&mut dep.package_id, &preserved_registry); + apply_preserved_registry( + &mut dep.package_id, + &preserved_registry, + canonical_registry, + ); } for deps in package.optional_dependencies.values_mut() { for dep in deps { - apply_preserved_registry(&mut dep.package_id, &preserved_registry); + apply_preserved_registry( + &mut dep.package_id, + &preserved_registry, + canonical_registry, + ); } } for deps in package.dependency_groups.values_mut() { for dep in deps { - apply_preserved_registry(&mut dep.package_id, &preserved_registry); + apply_preserved_registry( + &mut dep.package_id, + &preserved_registry, + canonical_registry, + ); } } } @@ -80,6 +112,9 @@ impl Lock { for new_package in &mut self.packages { let key = (&new_package.id.name, new_package.id.version.as_ref()); let Some(previous_package) = previous_by_key.get(&key).copied() else { + // fork: for newly added packages, we can't preserve sdist/wheel + // file URLs because we don't know what they'd be on the canonical + // index. The registry URL is still normalized above. continue; }; copy_sdist_url(previous_package.sdist.as_ref(), new_package.sdist.as_mut()); @@ -99,6 +134,7 @@ impl Lock { fn apply_preserved_registry( id: &mut PackageId, preserved: &FxHashMap<(&PackageName, Option<&Version>), &UrlString>, + canonical: Option<&UrlString>, ) { let Source::Registry(RegistrySource::Url(url)) = &mut id.source else { return; @@ -106,6 +142,9 @@ fn apply_preserved_registry( let key = (&id.name, id.version.as_ref()); if let Some(preserved_url) = preserved.get(&key) { *url = (*preserved_url).clone(); + } else if let Some(canonical_url) = canonical { + // Newly added package — normalize to the canonical registry URL. + *url = canonical_url.clone(); } } @@ -378,4 +417,51 @@ requires-python = ">=3.12" "registry URL should be untouched when previous has no match:\n{rendered}" ); } + + #[test] + fn newly_added_package_uses_canonical_registry() { + // Previous lock has iniconfig at pypi.org. + let previous = make_lock( + "https://pypi.org/simple", + "https://files.pythonhosted.org/packages/iniconfig", + ); + // New lock has iniconfig (via mirror) plus a newly added package "pytest". + let data = r#" +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://mirror.example.com/simple" } +sdist = { url = "https://mirror.example.com/files/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [{ url = "https://mirror.example.com/files/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }] + +[[package]] +name = "pytest" +version = "8.0.0" +source = { registry = "https://mirror.example.com/simple" } +sdist = { url = "https://mirror.example.com/files/pytest-8.0.0.tar.gz", hash = "sha256:0000000000000000000000000000000000000000000000000000000000000001", size = 1000 } +wheels = [{ url = "https://mirror.example.com/files/pytest-8.0.0-py3-none-any.whl", hash = "sha256:0000000000000000000000000000000000000000000000000000000000000002", size = 2000 }] +"#; + let mut new: Lock = toml::from_str(data).expect("parse lock"); + + new.rewrite_urls_from(&previous); + + let rendered = new.to_toml().expect("serialize lock"); + // The newly added pytest should use the canonical pypi.org registry, + // not the mirror URL. File URLs for new packages are left as-is since + // we can't infer canonical file URLs. + assert_eq!( + rendered + .matches(r#"registry = "https://pypi.org/simple""#) + .count(), + 2, + "both packages should use the canonical registry URL:\n{rendered}" + ); + assert!( + !rendered.contains(r#"registry = "https://mirror.example.com/simple""#), + "no mirror registry URLs should remain:\n{rendered}" + ); + } } From 33f8af8ef779d28ce58a146e03540c6b03f0d3f4 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:01:44 +0900 Subject: [PATCH 12/23] fork: replace previous-lock URL inference with UV_PYPI_PROXIES env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the complex "infer canonical URL from previous lockfile" logic with a simple env var mapping. UV_PYPI_PROXIES maps canonical URLs to proxy URLs (format: canonical:proxy,canonical2:proxy2). After resolution, rewrite_proxy_urls() replaces every matching proxy registry URL with its canonical counterpart. This handles both existing and newly added packages uniformly — no need to track the previous lockfile at all. Example: UV_PYPI_PROXIES=https://pypi.org/simple:https://pypi-proxy.dev.databricks.com/simple Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-resolver/src/lock/mod.rs | 13 +- .../uv-resolver/src/lock/url_preservation.rs | 426 +++++------------- crates/uv/src/commands/project/lock.rs | 22 +- 3 files changed, 122 insertions(+), 339 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 4d347bd91d180..03aebe6f085c8 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -1960,13 +1960,12 @@ impl Lock { // If the lockfile references an index that was not provided, we can't validate it. if let Source::Registry(index) = &package.id.source { match index { - // fork: skip the remote-index URL check. URL preservation - // (`rewrite_urls_from`) intentionally keeps the *previous* - // registry URL in the lockfile, which will not match the - // current `UV_DEFAULT_INDEX` when the user switches mirrors. - // Without this, `satisfies` returns `MissingRemoteIndex` on - // every run, defeating the lock cache and forcing a full - // re-resolve. + // fork: skip the remote-index URL check. UV_PYPI_PROXIES + // rewrites proxy registry URLs to canonical counterparts in + // the lockfile, which won't match the current + // UV_DEFAULT_INDEX. Without this, `satisfies` returns + // `MissingRemoteIndex` on every run, defeating the lock + // cache and forcing a full re-resolve. RegistrySource::Url(_) => {} RegistrySource::Path(path) => { if locals.as_ref().is_some_and(|locals| !locals.contains(path)) { diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index 999be4e050ac5..6eb89bc394c66 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -1,129 +1,89 @@ -//! fork: preserve URLs in `uv.lock` across re-locks when the index URL changes. +//! fork: rewrite proxy registry URLs in `uv.lock` to their canonical counterparts. //! -//! Upstream `uv lock` bakes the currently-resolved index URL — and file URLs -//! derived from it — into `uv.lock` via [`Source::Registry`], [`SourceDist::Url`], -//! and [`WheelWireSource::Url`]. When `UV_DEFAULT_INDEX` points at an internal -//! mirror that differs across environments, re-running `uv lock` rewrites these -//! URLs, causing noisy diffs and breaking portability. +//! When `UV_DEFAULT_INDEX` points at an internal PyPI mirror/proxy, upstream +//! `uv lock` bakes the proxy URL into every `source.registry` field in +//! `uv.lock`. This creates noisy diffs and breaks portability across +//! environments that use different mirrors. //! -//! This module adds [`Lock::rewrite_urls_from`], which copies URL fields from a -//! previous lockfile onto the newly-resolved lock when the package is still -//! present at the same (name, version). Hashes and versions continue to be -//! written as resolved; only URL fields are held stable. +//! The `UV_PYPI_PROXIES` environment variable provides a mapping from +//! canonical URLs to proxy URLs. After resolution, [`Lock::rewrite_proxy_urls`] +//! replaces every proxy registry URL with its canonical counterpart in the +//! lockfile, keeping it stable regardless of which mirror resolved the package. //! -//! Implementation notes: [`PackageId`] appears in two places inside the lock — -//! on each [`Package`] via `Package.id`, and on every [`Dependency`] via -//! `Dependency.package_id`. `PackageId` implements `Hash + Eq` over its `source` -//! field, so mutating the registry URL on `Package.id` without mutating the -//! matching `Dependency.package_id` breaks any `HashMap<&PackageId, _>` lookup -//! downstream (e.g. the dependency-graph build in `installable.rs`). To keep -//! the lock internally consistent we apply the URL substitution to every -//! `PackageId` occurrence. +//! Format: `:,:` //! -//! Non-registry sources (git, direct URL, path, directory, editable, virtual) -//! are left untouched. - -use rustc_hash::FxHashMap; +//! Example: +//! UV_PYPI_PROXIES=https://pypi.org/simple:https://pypi-proxy.dev.databricks.com/simple use uv_distribution_types::UrlString; -use uv_normalize::PackageName; -use uv_pep440::Version; -use super::{Lock, Package, PackageId, RegistrySource, Source, SourceDist, Wheel, WheelWireSource}; +use super::{Lock, PackageId, RegistrySource, Source}; -impl Lock { - /// Preserve URLs from a previous lockfile for packages whose (name, version) - /// are unchanged. See module-level docs for the matching rules. - /// - /// For newly added packages (not present in `previous`), the canonical - /// registry URL is inferred from the previous lockfile and applied so that - /// all registry packages use a consistent URL, even when the current - /// `UV_DEFAULT_INDEX` points at a different mirror. - pub fn rewrite_urls_from(&mut self, previous: &Self) { - // Determine the canonical registry URL from the previous lock. We pick - // the first registry URL we find — after URL preservation has been - // running, all packages share the same registry URL. - let canonical_registry: Option<&UrlString> = previous.packages.iter().find_map(|package| { - if let Source::Registry(RegistrySource::Url(url)) = &package.id.source { - Some(url) - } else { - None - } - }); +/// A single canonical ↔ proxy URL mapping. +struct ProxyMapping { + canonical: UrlString, + proxy: UrlString, +} - // Build a map of (name, version) → preserved registry URL from the - // previous lock. These are the URLs we want every PackageId with the - // same (name, version) to use after rewriting. - let preserved_registry: FxHashMap<(&PackageName, Option<&Version>), &UrlString> = previous - .packages - .iter() - .filter_map(|package| { - let Source::Registry(RegistrySource::Url(url)) = &package.id.source else { - return None; - }; - Some(((&package.id.name, package.id.version.as_ref()), url)) +/// Parse `UV_PYPI_PROXIES` into a list of mappings. +/// +/// Format: `canonical:proxy,canonical2:proxy2` +fn parse_proxy_mappings() -> Vec { + let Some(value) = std::env::var("UV_PYPI_PROXIES").ok() else { + return Vec::new(); + }; + value + .split(',') + .filter_map(|entry| { + let entry = entry.trim(); + if entry.is_empty() { + return None; + } + // Split on `:https://` or `:http://` to avoid splitting the scheme. + let delimiter_pos = entry + .find(":https://") + .or_else(|| entry.find(":http://")) + .filter(|&pos| pos > 0)?; + let canonical = entry[..delimiter_pos].trim(); + let proxy = entry[delimiter_pos + 1..].trim(); + if canonical.is_empty() || proxy.is_empty() { + return None; + } + Some(ProxyMapping { + canonical: UrlString::from(canonical), + proxy: UrlString::from(proxy), }) - .collect(); + }) + .collect() +} + +impl Lock { + /// Rewrite proxy registry URLs to their canonical counterparts based on + /// the `UV_PYPI_PROXIES` environment variable. + pub fn rewrite_proxy_urls(&mut self) { + let mappings = parse_proxy_mappings(); + if mappings.is_empty() { + return; + } - // Apply the registry URL mapping to every PackageId in the lock. This - // covers both `Package.id` and every `Dependency.package_id` nested - // inside a package's dependencies, optional dependencies, and - // dependency groups — all must stay in sync so downstream HashMap - // lookups (e.g. in `installable.rs`) still work. - // - // For packages not found in `preserved_registry`, fall back to the - // canonical registry URL so newly added packages also get the - // consistent URL. for package in &mut self.packages { - apply_preserved_registry(&mut package.id, &preserved_registry, canonical_registry); + apply_proxy_mapping(&mut package.id, &mappings); for dep in &mut package.dependencies { - apply_preserved_registry( - &mut dep.package_id, - &preserved_registry, - canonical_registry, - ); + apply_proxy_mapping(&mut dep.package_id, &mappings); } for deps in package.optional_dependencies.values_mut() { for dep in deps { - apply_preserved_registry( - &mut dep.package_id, - &preserved_registry, - canonical_registry, - ); + apply_proxy_mapping(&mut dep.package_id, &mappings); } } for deps in package.dependency_groups.values_mut() { for dep in deps { - apply_preserved_registry( - &mut dep.package_id, - &preserved_registry, - canonical_registry, - ); + apply_proxy_mapping(&mut dep.package_id, &mappings); } } } - // Preserve sdist / wheel URLs per-package. - let previous_by_key: FxHashMap<(&PackageName, Option<&Version>), &Package> = previous - .packages - .iter() - .map(|package| ((&package.id.name, package.id.version.as_ref()), package)) - .collect(); - for new_package in &mut self.packages { - let key = (&new_package.id.name, new_package.id.version.as_ref()); - let Some(previous_package) = previous_by_key.get(&key).copied() else { - // fork: for newly added packages, we can't preserve sdist/wheel - // file URLs because we don't know what they'd be on the canonical - // index. The registry URL is still normalized above. - continue; - }; - copy_sdist_url(previous_package.sdist.as_ref(), new_package.sdist.as_mut()); - copy_wheel_urls(&previous_package.wheels, &mut new_package.wheels); - } - - // `Lock.by_id` is a cached `PackageId → index` map built once during - // `Lock::new`. Since we just mutated `Package.id.source` on some entries, - // the cached keys are stale and `find_by_id` would panic. Rebuild it. + // Rebuild `by_id` since we mutated `Package.id.source`. self.by_id.clear(); for (index, package) in self.packages.iter().enumerate() { self.by_id.insert(package.id.clone(), index); @@ -131,55 +91,15 @@ impl Lock { } } -fn apply_preserved_registry( - id: &mut PackageId, - preserved: &FxHashMap<(&PackageName, Option<&Version>), &UrlString>, - canonical: Option<&UrlString>, -) { +/// Replace proxy URL with its canonical counterpart on a [`PackageId`]. +fn apply_proxy_mapping(id: &mut PackageId, mappings: &[ProxyMapping]) { let Source::Registry(RegistrySource::Url(url)) = &mut id.source else { return; }; - let key = (&id.name, id.version.as_ref()); - if let Some(preserved_url) = preserved.get(&key) { - *url = (*preserved_url).clone(); - } else if let Some(canonical_url) = canonical { - // Newly added package — normalize to the canonical registry URL. - *url = canonical_url.clone(); - } -} - -fn copy_sdist_url(previous: Option<&SourceDist>, new: Option<&mut SourceDist>) { - let (Some(previous), Some(new)) = (previous, new) else { - return; - }; - if let ( - SourceDist::Url { - url: previous_url, .. - }, - SourceDist::Url { url: new_url, .. }, - ) = (previous, new) - { - *new_url = previous_url.clone(); - } -} - -fn copy_wheel_urls(previous: &[Wheel], new: &mut [Wheel]) { - for new_wheel in new { - let matching_url = previous.iter().find_map(|previous_wheel| { - if previous_wheel.filename != new_wheel.filename { - return None; - } - if let WheelWireSource::Url { url } = &previous_wheel.url { - Some(url.clone()) - } else { - None - } - }); - let Some(matching_url) = matching_url else { - continue; - }; - if let WheelWireSource::Url { url: new_url } = &mut new_wheel.url { - *new_url = matching_url; + for mapping in mappings { + if *url == mapping.proxy { + *url = mapping.canonical.clone(); + return; } } } @@ -205,8 +125,6 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha toml::from_str(&data).expect("parse lock") } - /// Builds a two-package lockfile (`anyio` depending on `iniconfig`) at the - /// given registry/file prefix. Both packages use the same registry URL. fn make_lock_with_dependency(registry: &str, file_prefix: &str) -> Lock { let data = format!( r#" @@ -237,62 +155,45 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha } #[test] - fn preserves_urls_on_mirror_change() { - let previous = make_lock( - "https://pypi.org/simple", - "https://files.pythonhosted.org/packages/iniconfig", + fn rewrites_proxy_to_canonical() { + std::env::set_var( + "UV_PYPI_PROXIES", + "https://pypi.org/simple:https://mirror.example.com/simple", ); - let mut new = make_lock( + let mut lock = make_lock( "https://mirror.example.com/simple", "https://mirror.example.com/files/iniconfig", ); - new.rewrite_urls_from(&previous); + lock.rewrite_proxy_urls(); - let rendered = new.to_toml().expect("serialize lock"); + let rendered = lock.to_toml().expect("serialize lock"); assert!( rendered.contains(r#"registry = "https://pypi.org/simple""#), - "registry URL should be preserved from previous lock:\n{rendered}" - ); - assert!( - rendered.contains( - "https://files.pythonhosted.org/packages/iniconfig/iniconfig-2.0.0.tar.gz" - ), - "sdist URL should be preserved:\n{rendered}" + "registry URL should be rewritten to canonical:\n{rendered}" ); + // File URLs are not rewritten — only registry sources. assert!( - rendered.contains( - "https://files.pythonhosted.org/packages/iniconfig/iniconfig-2.0.0-py3-none-any.whl" - ), - "wheel URL should be preserved:\n{rendered}" - ); - assert!( - !rendered.contains("mirror.example.com"), - "no mirror URLs should leak into the rewritten lock:\n{rendered}" + rendered.contains("https://mirror.example.com/files/iniconfig"), + "file URLs should be left as-is:\n{rendered}" ); + std::env::remove_var("UV_PYPI_PROXIES"); } #[test] fn rewrites_dependency_package_ids() { - // Two-package lock with a dependency edge — guards against the panic - // in installable.rs:508 where `inverse[&package.id]` fails when - // `Package.id` and `Dependency.package_id` drift out of sync. - let previous = make_lock_with_dependency( - "https://pypi.org/simple", - "https://files.pythonhosted.org/packages", + std::env::set_var( + "UV_PYPI_PROXIES", + "https://pypi.org/simple:https://mirror.example.com/simple", ); - let mut new = make_lock_with_dependency( + let mut lock = make_lock_with_dependency( "https://mirror.example.com/simple", "https://mirror.example.com/files", ); - new.rewrite_urls_from(&previous); + lock.rewrite_proxy_urls(); - // Every `Dependency.package_id.source` must be updated in lockstep with - // `Package.id.source`; otherwise downstream HashMap lookups break. We - // verify this structurally: walk every dependency's PackageId and make - // sure no mirror URL survives. - for package in &new.packages { + for package in &lock.packages { for dep in &package.dependencies { if let super::Source::Registry(super::RegistrySource::Url(url)) = &dep.package_id.source @@ -306,162 +207,51 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha } } - // The re-serialized new lock must be byte-identical to the previous one - // (same resolution, URLs all preserved) — this is the invariant the - // skip-commit optimization relies on. - let new_rendered = new.to_toml().expect("serialize new lock"); - let previous_rendered = previous.to_toml().expect("serialize previous lock"); - assert_eq!( - new_rendered, previous_rendered, - "rewritten lock should match previous lock byte-for-byte\n\ - --- new ---\n{new_rendered}\n--- previous ---\n{previous_rendered}" - ); - - // `find_by_id` relies on the cached `Lock.by_id` map; if we forgot to - // rebuild it after mutating `Package.id.source` the lookup would panic - // (as `installable.rs:1464` did in practice). Walk every dependency - // and resolve its package to ensure no stale keys remain. - for package in &new.packages { + // find_by_id must work after rewrite (by_id was rebuilt). + for package in &lock.packages { for dep in &package.dependencies { - let resolved = new.find_by_id(&dep.package_id); - assert_eq!( - resolved.id.name, dep.package_id.name, - "find_by_id returned wrong package for dependency {}", - dep.package_id.name - ); + let resolved = lock.find_by_id(&dep.package_id); + assert_eq!(resolved.id.name, dep.package_id.name); } } + std::env::remove_var("UV_PYPI_PROXIES"); } #[test] - fn refreshes_urls_on_version_bump() { - let previous = make_lock( - "https://pypi.org/simple", - "https://files.pythonhosted.org/packages/iniconfig", + fn no_env_var_is_noop() { + std::env::remove_var("UV_PYPI_PROXIES"); + let mut lock = make_lock( + "https://mirror.example.com/simple", + "https://mirror.example.com/files/iniconfig", ); - let data = r#" -version = 1 -requires-python = ">=3.12" -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://mirror.example.com/simple" } -sdist = { url = "https://mirror.example.com/files/iniconfig/iniconfig-2.1.0.tar.gz", hash = "sha256:0000000000000000000000000000000000000000000000000000000000000001", size = 4646 } -wheels = [{ url = "https://mirror.example.com/files/iniconfig/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:0000000000000000000000000000000000000000000000000000000000000002", size = 5892 }] -"#; - let mut new: Lock = toml::from_str(data).expect("parse lock"); - - new.rewrite_urls_from(&previous); + lock.rewrite_proxy_urls(); - let rendered = new.to_toml().expect("serialize lock"); + let rendered = lock.to_toml().expect("serialize lock"); assert!( rendered.contains(r#"registry = "https://mirror.example.com/simple""#), - "registry URL should not be preserved across version bump:\n{rendered}" + "registry URL should be unchanged without env var:\n{rendered}" ); } #[test] - fn preserves_urls_but_keeps_new_hash_when_hash_differs() { - let previous = make_lock( - "https://pypi.org/simple", - "https://files.pythonhosted.org/packages/iniconfig", + fn no_match_leaves_urls_untouched() { + std::env::set_var( + "UV_PYPI_PROXIES", + "https://pypi.org/simple:https://other-proxy.example.com/simple", ); - let data = r#" -version = 1 -requires-python = ">=3.12" - -[[package]] -name = "iniconfig" -version = "2.0.0" -source = { registry = "https://mirror.example.com/simple" } -sdist = { url = "https://mirror.example.com/files/iniconfig/iniconfig-2.0.0.tar.gz", hash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", size = 4646 } -wheels = [{ url = "https://mirror.example.com/files/iniconfig/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", size = 5892 }] -"#; - let mut new: Lock = toml::from_str(data).expect("parse lock"); - - new.rewrite_urls_from(&previous); - - let rendered = new.to_toml().expect("serialize lock"); - assert!( - !rendered.contains("mirror.example.com"), - "mirror URLs should not leak:\n{rendered}" - ); - assert!( - rendered.contains("aaaaaaaaaaaa"), - "new sdist hash should be kept:\n{rendered}" - ); - assert!( - rendered.contains("bbbbbbbbbbbb"), - "new wheel hash should be kept:\n{rendered}" - ); - } - - #[test] - fn no_previous_match_leaves_urls_untouched() { - let previous_data = r#" -version = 1 -requires-python = ">=3.12" -"#; - let previous: Lock = toml::from_str(previous_data).expect("parse lock"); - let mut new = make_lock( + let mut lock = make_lock( "https://mirror.example.com/simple", "https://mirror.example.com/files/iniconfig", ); - new.rewrite_urls_from(&previous); + lock.rewrite_proxy_urls(); - let rendered = new.to_toml().expect("serialize lock"); + let rendered = lock.to_toml().expect("serialize lock"); assert!( rendered.contains(r#"registry = "https://mirror.example.com/simple""#), - "registry URL should be untouched when previous has no match:\n{rendered}" - ); - } - - #[test] - fn newly_added_package_uses_canonical_registry() { - // Previous lock has iniconfig at pypi.org. - let previous = make_lock( - "https://pypi.org/simple", - "https://files.pythonhosted.org/packages/iniconfig", - ); - // New lock has iniconfig (via mirror) plus a newly added package "pytest". - let data = r#" -version = 1 -requires-python = ">=3.12" - -[[package]] -name = "iniconfig" -version = "2.0.0" -source = { registry = "https://mirror.example.com/simple" } -sdist = { url = "https://mirror.example.com/files/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } -wheels = [{ url = "https://mirror.example.com/files/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }] - -[[package]] -name = "pytest" -version = "8.0.0" -source = { registry = "https://mirror.example.com/simple" } -sdist = { url = "https://mirror.example.com/files/pytest-8.0.0.tar.gz", hash = "sha256:0000000000000000000000000000000000000000000000000000000000000001", size = 1000 } -wheels = [{ url = "https://mirror.example.com/files/pytest-8.0.0-py3-none-any.whl", hash = "sha256:0000000000000000000000000000000000000000000000000000000000000002", size = 2000 }] -"#; - let mut new: Lock = toml::from_str(data).expect("parse lock"); - - new.rewrite_urls_from(&previous); - - let rendered = new.to_toml().expect("serialize lock"); - // The newly added pytest should use the canonical pypi.org registry, - // not the mirror URL. File URLs for new packages are left as-is since - // we can't infer canonical file URLs. - assert_eq!( - rendered - .matches(r#"registry = "https://pypi.org/simple""#) - .count(), - 2, - "both packages should use the canonical registry URL:\n{rendered}" - ); - assert!( - !rendered.contains(r#"registry = "https://mirror.example.com/simple""#), - "no mirror registry URLs should remain:\n{rendered}" + "non-matching registry URL should be unchanged:\n{rendered}" ); + std::env::remove_var("UV_PYPI_PROXIES"); } } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 4edd0a3f7cf0d..2ef937de0595c 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -453,20 +453,14 @@ impl<'env> LockOperation<'env> { )) .await?; - // fork: preserve URLs across re-locks; see issue astral-sh/uv#6349. - // If URL preservation makes the new lock equal to the previous one, - // skip the write so we don't touch uv.lock's mtime. - let mut skip_commit = false; - if let LockResult::Changed(Some(previous), lock) = &mut result { - lock.rewrite_urls_from(previous); - if lock == previous { - skip_commit = true; - } - } - - // If the lockfile changed, write it to disk. - if !matches!(self.mode, LockMode::DryRun(_)) && !skip_commit { - if let LockResult::Changed(_, lock) = &result { + // fork: rewrite proxy registry URLs to their canonical + // counterparts based on UV_PYPI_PROXIES; see astral-sh/uv#6349. + if let LockResult::Changed(previous, lock) = &mut result { + lock.rewrite_proxy_urls(); + if previous.as_ref().is_some_and(|prev| prev == lock) { + // URL rewrite made the new lock identical to the + // previous one — skip the write to avoid touching mtime. + } else if !matches!(self.mode, LockMode::DryRun(_)) { target.commit(lock).await?; } } From e48fa2ad50b1f3371387efbcc807c28de660ae6f Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:06:57 +0900 Subject: [PATCH 13/23] =?UTF-8?q?fork:=20simplify=20mod.rs=20=E2=80=94=20r?= =?UTF-8?q?estore=20upstream=20code,=20inject=20canonical=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of commenting out the entire remotes collection and check in satisfies(), restore the original upstream code and just add canonical URLs from UV_PYPI_PROXIES to the remotes set. This reduces the mod.rs diff from ~60 lines of comments to 8 added lines. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-resolver/src/lock/mod.rs | 129 +++++++++--------- .../uv-resolver/src/lock/url_preservation.rs | 11 ++ 2 files changed, 74 insertions(+), 66 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 03aebe6f085c8..93689ae1a5a8d 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -72,7 +72,7 @@ mod export; mod installable; mod map; mod tree; -// fork: preserve URLs across re-locks; see issue astral-sh/uv#6349. +// fork: rewrite proxy registry URLs via UV_PYPI_PROXIES; see astral-sh/uv#6349. mod url_preservation; /// The current version of the lockfile format. @@ -1826,21 +1826,24 @@ impl Lock { } // Collect the set of available indexes (both `--index-url` and `--find-links` entries). - // fork: remote index collection commented out — URL preservation means the - // lockfile intentionally keeps previous registry URLs that may not match the - // current index configuration. - // let mut remotes = indexes.map(|locations| { - // locations - // .allowed_indexes() - // .into_iter() - // .filter_map(|index| match index.url() { - // IndexUrl::Pypi(_) | IndexUrl::Url(_) => { - // Some(UrlString::from(index.url().without_credentials().as_ref())) - // } - // IndexUrl::Path(_) => None, - // }) - // .collect::>() - // }); + let mut remotes = indexes.map(|locations| { + locations + .allowed_indexes() + .into_iter() + .filter_map(|index| match index.url() { + IndexUrl::Pypi(_) | IndexUrl::Url(_) => { + Some(UrlString::from(index.url().without_credentials().as_ref())) + } + IndexUrl::Path(_) => None, + }) + .collect::>() + }); + + // fork: add canonical URLs from UV_PYPI_PROXIES so that the + // satisfies check recognizes them as valid remote indexes. + if let Some(remotes) = remotes.as_mut() { + url_preservation::canonical_urls(remotes); + } let mut locals = indexes.map(|locations| { locations @@ -1887,23 +1890,21 @@ impl Lock { index: Some(index), .. } = &requirement.source { - // fork: remote index insertion commented out — see note above. - // match &index.url { - // IndexUrl::Pypi(_) | IndexUrl::Url(_) => { - // if let Some(remotes) = remotes.as_mut() { - // remotes.insert(UrlString::from( - // index.url().without_credentials().as_ref(), - // )); - // } - // } - // IndexUrl::Path(url) => { ... } - // } - if let IndexUrl::Path(url) = &index.url { - if let Some(locals) = locals.as_mut() { - if let Some(path) = url.to_file_path().ok().and_then(|path| { - try_relative_to_if(&path, root, !url.was_given_absolute()).ok() - }) { - locals.insert(path.into_boxed_path()); + match &index.url { + IndexUrl::Pypi(_) | IndexUrl::Url(_) => { + if let Some(remotes) = remotes.as_mut() { + remotes.insert(UrlString::from( + index.url().without_credentials().as_ref(), + )); + } + } + IndexUrl::Path(url) => { + if let Some(locals) = locals.as_mut() { + if let Some(path) = url.to_file_path().ok().and_then(|path| { + try_relative_to_if(&path, root, !url.was_given_absolute()).ok() + }) { + locals.insert(path.into_boxed_path()); + } } } } @@ -1960,13 +1961,20 @@ impl Lock { // If the lockfile references an index that was not provided, we can't validate it. if let Source::Registry(index) = &package.id.source { match index { - // fork: skip the remote-index URL check. UV_PYPI_PROXIES - // rewrites proxy registry URLs to canonical counterparts in - // the lockfile, which won't match the current - // UV_DEFAULT_INDEX. Without this, `satisfies` returns - // `MissingRemoteIndex` on every run, defeating the lock - // cache and forcing a full re-resolve. - RegistrySource::Url(_) => {} + RegistrySource::Url(url) => { + if remotes + .as_ref() + .is_some_and(|remotes| !remotes.contains(url)) + { + let name = &package.id.name; + let version = &package + .id + .version + .as_ref() + .expect("version for registry source"); + return Ok(SatisfiesResult::MissingRemoteIndex(name, version, url)); + } + } RegistrySource::Path(path) => { if locals.as_ref().is_some_and(|locals| !locals.contains(path)) { let name = &package.id.name; @@ -2256,32 +2264,21 @@ impl Lock { index: Some(index), .. } = &requirement.source { - // fork: remote index insertion commented out — URL preservation - // keeps previous registry URLs, so we don't need to track remotes. - // match &index.url { - // IndexUrl::Pypi(_) | IndexUrl::Url(_) => { - // if let Some(remotes) = remotes.as_mut() { - // remotes.insert(UrlString::from( - // index.url().without_credentials().as_ref(), - // )); - // } - // } - // IndexUrl::Path(url) => { - // if let Some(locals) = locals.as_mut() { - // if let Some(path) = url.to_file_path().ok().and_then(|path| { - // try_relative_to_if(&path, root, !url.was_given_absolute()).ok() - // }) { - // locals.insert(path.into_boxed_path()); - // } - // } - // } - // } - if let IndexUrl::Path(url) = &index.url { - if let Some(locals) = locals.as_mut() { - if let Some(path) = url.to_file_path().ok().and_then(|path| { - try_relative_to_if(&path, root, !url.was_given_absolute()).ok() - }) { - locals.insert(path.into_boxed_path()); + match &index.url { + IndexUrl::Pypi(_) | IndexUrl::Url(_) => { + if let Some(remotes) = remotes.as_mut() { + remotes.insert(UrlString::from( + index.url().without_credentials().as_ref(), + )); + } + } + IndexUrl::Path(url) => { + if let Some(locals) = locals.as_mut() { + if let Some(path) = url.to_file_path().ok().and_then(|path| { + try_relative_to_if(&path, root, !url.was_given_absolute()).ok() + }) { + locals.insert(path.into_boxed_path()); + } } } } diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index 6eb89bc394c66..8331b194e0537 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -15,6 +15,8 @@ //! Example: //! UV_PYPI_PROXIES=https://pypi.org/simple:https://pypi-proxy.dev.databricks.com/simple +use std::collections::BTreeSet; + use uv_distribution_types::UrlString; use super::{Lock, PackageId, RegistrySource, Source}; @@ -57,6 +59,15 @@ fn parse_proxy_mappings() -> Vec { .collect() } +/// Add canonical URLs from `UV_PYPI_PROXIES` to the set of known remote +/// indexes so that `satisfies()` recognizes lockfile entries written with +/// canonical URLs as valid. +pub(super) fn canonical_urls(remotes: &mut BTreeSet) { + for mapping in parse_proxy_mappings() { + remotes.insert(mapping.canonical); + } +} + impl Lock { /// Rewrite proxy registry URLs to their canonical counterparts based on /// the `UV_PYPI_PROXIES` environment variable. From 34eccb69dcb6acba138307a06c20a2bfec87936e Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:13:09 +0900 Subject: [PATCH 14/23] fork: replace internal URL with example.com in docs Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-resolver/src/lock/url_preservation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index 8331b194e0537..4cdca05b7e701 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -13,7 +13,7 @@ //! Format: `:,:` //! //! Example: -//! UV_PYPI_PROXIES=https://pypi.org/simple:https://pypi-proxy.dev.databricks.com/simple +//! UV_PYPI_PROXIES=https://pypi.org/simple:https://pypi-proxy.example.com/simple use std::collections::BTreeSet; From 22543451abab407917ade08944fc841f551bf233 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:14:39 +0900 Subject: [PATCH 15/23] =?UTF-8?q?fork:=20fix=20UrlString=20construction=20?= =?UTF-8?q?=E2=80=94=20use=20SmallString::from=20for=20&str?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UrlString doesn't implement From<&str>; use UrlString::new(SmallString::from(...)) to construct from string slices. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-resolver/src/lock/url_preservation.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index 4cdca05b7e701..00d2aa0eba7b8 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -18,6 +18,7 @@ use std::collections::BTreeSet; use uv_distribution_types::UrlString; +use uv_small_str::SmallString; use super::{Lock, PackageId, RegistrySource, Source}; @@ -52,8 +53,8 @@ fn parse_proxy_mappings() -> Vec { return None; } Some(ProxyMapping { - canonical: UrlString::from(canonical), - proxy: UrlString::from(proxy), + canonical: UrlString::new(SmallString::from(canonical)), + proxy: UrlString::new(SmallString::from(proxy)), }) }) .collect() From 4f78fca8c62777c97f5622a255e430c58adc9bec Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:26:34 +0900 Subject: [PATCH 16/23] fork-release: add UV_PYPI_PROXIES setup instructions to release notes Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/fork-release.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index fc751afa774a2..24cbc0d486b79 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -98,9 +98,21 @@ jobs: ## Fork behavior - This fork preserves registry, sdist, and wheel URLs in \`uv.lock\` across re-locks - when the package name and version are unchanged. See - [astral-sh/uv#6349](https://github.com/astral-sh/uv/issues/6349). + This fork rewrites proxy registry URLs in \`uv.lock\` to their canonical counterparts + via the \`UV_PYPI_PROXIES\` environment variable. This prevents noisy diffs and keeps + the lockfile portable across environments that use different PyPI mirrors. + See [astral-sh/uv#6349](https://github.com/astral-sh/uv/issues/6349). + + ## Setup + + Set \`UV_PYPI_PROXIES\` with \`canonical:proxy\` mappings (comma-separated for multiple): + + \`\`\`bash + export UV_PYPI_PROXIES=https://pypi.org/simple:https://your-pypi-proxy.example.com/simple + \`\`\` + + Then use \`uv lock\`, \`uv add\`, etc. as normal. The lockfile will always contain the + canonical URL (\`https://pypi.org/simple\`) regardless of which mirror resolved the package. ## Install From fb0a97ecf65908fdb04f599c61750656cf4e6360 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:29:46 +0900 Subject: [PATCH 17/23] fork: rename UV_PYPI_PROXIES to UV_INDEX_PROXIES Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/fork-release.yml | 6 ++--- crates/uv-resolver/src/lock/mod.rs | 4 +-- .../uv-resolver/src/lock/url_preservation.rs | 26 +++++++++---------- crates/uv/src/commands/project/lock.rs | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index 24cbc0d486b79..33a5aa0d67938 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -99,16 +99,16 @@ jobs: ## Fork behavior This fork rewrites proxy registry URLs in \`uv.lock\` to their canonical counterparts - via the \`UV_PYPI_PROXIES\` environment variable. This prevents noisy diffs and keeps + via the \`UV_INDEX_PROXIES\` environment variable. This prevents noisy diffs and keeps the lockfile portable across environments that use different PyPI mirrors. See [astral-sh/uv#6349](https://github.com/astral-sh/uv/issues/6349). ## Setup - Set \`UV_PYPI_PROXIES\` with \`canonical:proxy\` mappings (comma-separated for multiple): + Set \`UV_INDEX_PROXIES\` with \`canonical:proxy\` mappings (comma-separated for multiple): \`\`\`bash - export UV_PYPI_PROXIES=https://pypi.org/simple:https://your-pypi-proxy.example.com/simple + export UV_INDEX_PROXIES=https://pypi.org/simple:https://your-pypi-proxy.example.com/simple \`\`\` Then use \`uv lock\`, \`uv add\`, etc. as normal. The lockfile will always contain the diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 93689ae1a5a8d..96ca822634d43 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -72,7 +72,7 @@ mod export; mod installable; mod map; mod tree; -// fork: rewrite proxy registry URLs via UV_PYPI_PROXIES; see astral-sh/uv#6349. +// fork: rewrite proxy registry URLs via UV_INDEX_PROXIES; see astral-sh/uv#6349. mod url_preservation; /// The current version of the lockfile format. @@ -1839,7 +1839,7 @@ impl Lock { .collect::>() }); - // fork: add canonical URLs from UV_PYPI_PROXIES so that the + // fork: add canonical URLs from UV_INDEX_PROXIES so that the // satisfies check recognizes them as valid remote indexes. if let Some(remotes) = remotes.as_mut() { url_preservation::canonical_urls(remotes); diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index 00d2aa0eba7b8..a98b5e431c0dc 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -5,7 +5,7 @@ //! `uv.lock`. This creates noisy diffs and breaks portability across //! environments that use different mirrors. //! -//! The `UV_PYPI_PROXIES` environment variable provides a mapping from +//! The `UV_INDEX_PROXIES` environment variable provides a mapping from //! canonical URLs to proxy URLs. After resolution, [`Lock::rewrite_proxy_urls`] //! replaces every proxy registry URL with its canonical counterpart in the //! lockfile, keeping it stable regardless of which mirror resolved the package. @@ -13,7 +13,7 @@ //! Format: `:,:` //! //! Example: -//! UV_PYPI_PROXIES=https://pypi.org/simple:https://pypi-proxy.example.com/simple +//! UV_INDEX_PROXIES=https://pypi.org/simple:https://pypi-proxy.example.com/simple use std::collections::BTreeSet; @@ -28,11 +28,11 @@ struct ProxyMapping { proxy: UrlString, } -/// Parse `UV_PYPI_PROXIES` into a list of mappings. +/// Parse `UV_INDEX_PROXIES` into a list of mappings. /// /// Format: `canonical:proxy,canonical2:proxy2` fn parse_proxy_mappings() -> Vec { - let Some(value) = std::env::var("UV_PYPI_PROXIES").ok() else { + let Some(value) = std::env::var("UV_INDEX_PROXIES").ok() else { return Vec::new(); }; value @@ -60,7 +60,7 @@ fn parse_proxy_mappings() -> Vec { .collect() } -/// Add canonical URLs from `UV_PYPI_PROXIES` to the set of known remote +/// Add canonical URLs from `UV_INDEX_PROXIES` to the set of known remote /// indexes so that `satisfies()` recognizes lockfile entries written with /// canonical URLs as valid. pub(super) fn canonical_urls(remotes: &mut BTreeSet) { @@ -71,7 +71,7 @@ pub(super) fn canonical_urls(remotes: &mut BTreeSet) { impl Lock { /// Rewrite proxy registry URLs to their canonical counterparts based on - /// the `UV_PYPI_PROXIES` environment variable. + /// the `UV_INDEX_PROXIES` environment variable. pub fn rewrite_proxy_urls(&mut self) { let mappings = parse_proxy_mappings(); if mappings.is_empty() { @@ -169,7 +169,7 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha #[test] fn rewrites_proxy_to_canonical() { std::env::set_var( - "UV_PYPI_PROXIES", + "UV_INDEX_PROXIES", "https://pypi.org/simple:https://mirror.example.com/simple", ); let mut lock = make_lock( @@ -189,13 +189,13 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha rendered.contains("https://mirror.example.com/files/iniconfig"), "file URLs should be left as-is:\n{rendered}" ); - std::env::remove_var("UV_PYPI_PROXIES"); + std::env::remove_var("UV_INDEX_PROXIES"); } #[test] fn rewrites_dependency_package_ids() { std::env::set_var( - "UV_PYPI_PROXIES", + "UV_INDEX_PROXIES", "https://pypi.org/simple:https://mirror.example.com/simple", ); let mut lock = make_lock_with_dependency( @@ -226,12 +226,12 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha assert_eq!(resolved.id.name, dep.package_id.name); } } - std::env::remove_var("UV_PYPI_PROXIES"); + std::env::remove_var("UV_INDEX_PROXIES"); } #[test] fn no_env_var_is_noop() { - std::env::remove_var("UV_PYPI_PROXIES"); + std::env::remove_var("UV_INDEX_PROXIES"); let mut lock = make_lock( "https://mirror.example.com/simple", "https://mirror.example.com/files/iniconfig", @@ -249,7 +249,7 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha #[test] fn no_match_leaves_urls_untouched() { std::env::set_var( - "UV_PYPI_PROXIES", + "UV_INDEX_PROXIES", "https://pypi.org/simple:https://other-proxy.example.com/simple", ); let mut lock = make_lock( @@ -264,6 +264,6 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha rendered.contains(r#"registry = "https://mirror.example.com/simple""#), "non-matching registry URL should be unchanged:\n{rendered}" ); - std::env::remove_var("UV_PYPI_PROXIES"); + std::env::remove_var("UV_INDEX_PROXIES"); } } diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 2ef937de0595c..529f578293734 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -454,7 +454,7 @@ impl<'env> LockOperation<'env> { .await?; // fork: rewrite proxy registry URLs to their canonical - // counterparts based on UV_PYPI_PROXIES; see astral-sh/uv#6349. + // counterparts based on UV_INDEX_PROXIES; see astral-sh/uv#6349. if let LockResult::Changed(previous, lock) = &mut result { lock.rewrite_proxy_urls(); if previous.as_ref().is_some_and(|prev| prev == lock) { From f1654e63fb019bb32ac9828c0bd890e080263245 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:57:35 +0900 Subject: [PATCH 18/23] fork: also rewrite proxy URLs on Unchanged lock results When satisfies() returns Satisfied (lockfile already up-to-date), the result is Unchanged and the rewrite_proxy_urls code path was skipped. Now handle both Changed and Unchanged cases so proxy URLs get rewritten to canonical even when no re-resolution was needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv/src/commands/project/lock.rs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 529f578293734..98f6ba94fc1d6 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -455,13 +455,23 @@ impl<'env> LockOperation<'env> { // fork: rewrite proxy registry URLs to their canonical // counterparts based on UV_INDEX_PROXIES; see astral-sh/uv#6349. - if let LockResult::Changed(previous, lock) = &mut result { - lock.rewrite_proxy_urls(); - if previous.as_ref().is_some_and(|prev| prev == lock) { - // URL rewrite made the new lock identical to the - // previous one — skip the write to avoid touching mtime. - } else if !matches!(self.mode, LockMode::DryRun(_)) { - target.commit(lock).await?; + match &mut result { + LockResult::Changed(previous, lock) => { + lock.rewrite_proxy_urls(); + if previous.as_ref().is_some_and(|prev| prev == lock) { + // URL rewrite made the new lock identical to the + // previous one — skip the write to avoid touching mtime. + } else if !matches!(self.mode, LockMode::DryRun(_)) { + target.commit(lock).await?; + } + } + LockResult::Unchanged(lock) => { + let before = lock.to_toml()?; + lock.rewrite_proxy_urls(); + let after = lock.to_toml()?; + if before != after && !matches!(self.mode, LockMode::DryRun(_)) { + target.commit(lock).await?; + } } } From 56720678bcb0fc3c79c49cdb20e8a54a9e7bb500 Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:11:26 +0900 Subject: [PATCH 19/23] fork: resolve canonical URLs back to proxy for uv sync The lockfile stores canonical URLs (e.g. pypi.org/simple), but uv sync needs to fetch from the proxy that is actually reachable. Add proxy_url() reverse mapping and apply it at the 3 points where RegistrySource::Url is converted to IndexUrl for HTTP fetching. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-resolver/src/lock/mod.rs | 15 ++++- .../uv-resolver/src/lock/url_preservation.rs | 57 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 96ca822634d43..255b76f6335cf 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -3278,8 +3278,11 @@ impl Package { zstd: None, }); + // fork: resolve canonical URL back to proxy for fetching; + // see astral-sh/uv#6349. + let resolved_url = url_preservation::proxy_url(url); let index = IndexUrl::from(VerbatimUrl::from_url( - url.to_url().map_err(LockErrorKind::InvalidUrl)?, + resolved_url.to_url().map_err(LockErrorKind::InvalidUrl)?, )); let reg_dist = RegistrySourceDist { @@ -3574,8 +3577,11 @@ impl Package { pub fn index(&self, root: &Path) -> Result, LockError> { match &self.id.source { Source::Registry(RegistrySource::Url(url)) => { + // fork: resolve canonical URL back to proxy for fetching; + // see astral-sh/uv#6349. + let resolved_url = url_preservation::proxy_url(url); let index = IndexUrl::from(VerbatimUrl::from_url( - url.to_url().map_err(LockErrorKind::InvalidUrl)?, + resolved_url.to_url().map_err(LockErrorKind::InvalidUrl)?, )); Ok(Some(index)) } @@ -5062,8 +5068,11 @@ impl Wheel { }) .map(Box::new), }); + // fork: resolve canonical URL back to proxy for fetching; + // see astral-sh/uv#6349. + let resolved_url = url_preservation::proxy_url(url); let index = IndexUrl::from(VerbatimUrl::from_url( - url.to_url().map_err(LockErrorKind::InvalidUrl)?, + resolved_url.to_url().map_err(LockErrorKind::InvalidUrl)?, )); Ok(RegistryBuiltWheel { filename, diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index a98b5e431c0dc..6f44e73e4fdae 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -69,6 +69,21 @@ pub(super) fn canonical_urls(remotes: &mut BTreeSet) { } } +/// Resolve a canonical registry URL back to its proxy URL using +/// `UV_INDEX_PROXIES`. This is the reverse of [`Lock::rewrite_proxy_urls`]: +/// the lockfile stores canonical URLs, but at install time we need to fetch +/// from the proxy that is actually reachable. +/// +/// Returns the original URL unchanged if no mapping matches. +pub(super) fn proxy_url(url: &UrlString) -> UrlString { + for mapping in parse_proxy_mappings() { + if *url == mapping.canonical { + return mapping.proxy; + } + } + url.clone() +} + impl Lock { /// Rewrite proxy registry URLs to their canonical counterparts based on /// the `UV_INDEX_PROXIES` environment variable. @@ -119,6 +134,8 @@ fn apply_proxy_mapping(id: &mut PackageId, mappings: &[ProxyMapping]) { #[cfg(test)] mod tests { use super::super::Lock; + use uv_distribution_types::UrlString; + use uv_small_str::SmallString; fn make_lock(registry: &str, file_prefix: &str) -> Lock { let data = format!( @@ -266,4 +283,44 @@ wheels = [{{ url = "{file_prefix}/iniconfig-2.0.0-py3-none-any.whl", hash = "sha ); std::env::remove_var("UV_INDEX_PROXIES"); } + + #[test] + fn proxy_url_resolves_canonical_to_proxy() { + std::env::set_var( + "UV_INDEX_PROXIES", + "https://pypi.org/simple:https://mirror.example.com/simple", + ); + + let canonical = UrlString::new(SmallString::from("https://pypi.org/simple")); + let resolved = super::proxy_url(&canonical); + assert_eq!( + resolved.to_string(), + "https://mirror.example.com/simple", + "canonical URL should resolve to proxy URL" + ); + + // Non-matching URL should be returned unchanged. + let other = UrlString::new(SmallString::from("https://other.example.com/simple")); + let resolved = super::proxy_url(&other); + assert_eq!( + resolved.to_string(), + "https://other.example.com/simple", + "non-matching URL should be returned unchanged" + ); + + std::env::remove_var("UV_INDEX_PROXIES"); + } + + #[test] + fn proxy_url_noop_without_env() { + std::env::remove_var("UV_INDEX_PROXIES"); + + let url = UrlString::new(SmallString::from("https://pypi.org/simple")); + let resolved = super::proxy_url(&url); + assert_eq!( + resolved.to_string(), + "https://pypi.org/simple", + "URL should be unchanged without env var" + ); + } } From 25f12570a668750e0019e5f7d5ff152c8c38d6cd Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:16:40 +0900 Subject: [PATCH 20/23] fork: add debug logging for proxy URL resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log when rewriting proxy→canonical (lock) and canonical→proxy (sync) so the mapping is visible in verbose mode (`uv -v`). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-resolver/src/lock/url_preservation.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index 6f44e73e4fdae..a88a97a2970df 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -17,6 +17,7 @@ use std::collections::BTreeSet; +use tracing::debug; use uv_distribution_types::UrlString; use uv_small_str::SmallString; @@ -78,6 +79,10 @@ pub(super) fn canonical_urls(remotes: &mut BTreeSet) { pub(super) fn proxy_url(url: &UrlString) -> UrlString { for mapping in parse_proxy_mappings() { if *url == mapping.canonical { + debug!( + "Resolving canonical registry URL `{url}` to proxy `{}`", + mapping.proxy + ); return mapping.proxy; } } @@ -125,6 +130,10 @@ fn apply_proxy_mapping(id: &mut PackageId, mappings: &[ProxyMapping]) { }; for mapping in mappings { if *url == mapping.proxy { + debug!( + "Rewriting proxy registry URL `{url}` to canonical `{}`", + mapping.canonical + ); *url = mapping.canonical.clone(); return; } From 8448d0b4d207791c82ab990e60e0e63a8771131f Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:49:05 +0900 Subject: [PATCH 21/23] fork: apply UV_INDEX_PROXIES to default index for build resolution When no explicit default index is configured, uv falls back to pypi.org/simple. If UV_INDEX_PROXIES maps pypi.org to a proxy, the default index now resolves through the proxy so that build-system.requires (e.g. setuptools) can be fetched without direct pypi.org access. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-distribution-types/src/index_url.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index 5de8cce93070b..e3abaa985ea4d 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -26,6 +26,32 @@ static DEFAULT_INDEX: LazyLock = LazyLock::new(|| { )))) }); +// fork: default index resolved through UV_INDEX_PROXIES so that +// build-system.requires resolution uses the proxy; see astral-sh/uv#6349. +static DEFAULT_INDEX_PROXIED: LazyLock> = LazyLock::new(|| { + let value = std::env::var("UV_INDEX_PROXIES").ok()?; + let pypi = PYPI_URL.to_string(); + for entry in value.split(',') { + let entry = entry.trim(); + let delimiter_pos = entry + .find(":https://") + .or_else(|| entry.find(":http://")) + .filter(|&pos| pos > 0)?; + let canonical = entry[..delimiter_pos].trim(); + let proxy = entry[delimiter_pos + 1..].trim(); + if canonical == pypi { + let proxy_url = DisplaySafeUrl::parse(proxy).ok()?; + tracing::debug!( + "Resolving default index `{canonical}` to proxy `{proxy}` for build resolution" + ); + return Some(Index::from_index_url(IndexUrl::Url(Arc::new( + VerbatimUrl::from_url(proxy_url), + )))); + } + } + None +}); + /// The URL of an index to use for fetching packages (e.g., PyPI). #[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)] pub enum IndexUrl { @@ -313,6 +339,10 @@ impl<'a> IndexLocations { .iter() .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))) .find(|index| index.default) + // fork: prefer proxy-resolved default index so that + // build-system.requires resolution uses the proxy; + // see astral-sh/uv#6349. + .or_else(|| DEFAULT_INDEX_PROXIED.as_ref()) .or_else(|| Some(&DEFAULT_INDEX)) } } From bb83b9af493d4a9901d8f1414790ab1bc38ba53d Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:16:27 +0900 Subject: [PATCH 22/23] =?UTF-8?q?fork:=20reduce=20proxy=20URL=20log=20nois?= =?UTF-8?q?e=20=E2=80=94=20use=20trace=20for=20per-package,=20debug=20for?= =?UTF-8?q?=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-resolver/src/lock/url_preservation.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/uv-resolver/src/lock/url_preservation.rs b/crates/uv-resolver/src/lock/url_preservation.rs index a88a97a2970df..6ee69c9f2b6b9 100644 --- a/crates/uv-resolver/src/lock/url_preservation.rs +++ b/crates/uv-resolver/src/lock/url_preservation.rs @@ -17,7 +17,7 @@ use std::collections::BTreeSet; -use tracing::debug; +use tracing::{debug, trace}; use uv_distribution_types::UrlString; use uv_small_str::SmallString; @@ -79,7 +79,7 @@ pub(super) fn canonical_urls(remotes: &mut BTreeSet) { pub(super) fn proxy_url(url: &UrlString) -> UrlString { for mapping in parse_proxy_mappings() { if *url == mapping.canonical { - debug!( + trace!( "Resolving canonical registry URL `{url}` to proxy `{}`", mapping.proxy ); @@ -98,6 +98,13 @@ impl Lock { return; } + for mapping in &mappings { + debug!( + "Rewriting proxy registry URLs: `{}` → `{}`", + mapping.proxy, mapping.canonical + ); + } + for package in &mut self.packages { apply_proxy_mapping(&mut package.id, &mappings); for dep in &mut package.dependencies { @@ -130,7 +137,7 @@ fn apply_proxy_mapping(id: &mut PackageId, mappings: &[ProxyMapping]) { }; for mapping in mappings { if *url == mapping.proxy { - debug!( + trace!( "Rewriting proxy registry URL `{url}` to canonical `{}`", mapping.canonical ); From ffe4a3f961fafe1883d6ba66068de9e26fcf2fbb Mon Sep 17 00:00:00 2001 From: harupy <17039389+harupy@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:25:57 +0900 Subject: [PATCH 23/23] fork: apply UV_INDEX_PROXIES to IndexUrls::default_index() too The previous fix only patched IndexLocations::default_index(), but the resolver uses IndexUrls::default_index() which had a separate fallback to the hardcoded PyPI URL. Apply the same proxy mapping here so that uv lock also fetches through the proxy. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/uv-distribution-types/src/index_url.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/uv-distribution-types/src/index_url.rs b/crates/uv-distribution-types/src/index_url.rs index e3abaa985ea4d..273c22b700f6e 100644 --- a/crates/uv-distribution-types/src/index_url.rs +++ b/crates/uv-distribution-types/src/index_url.rs @@ -550,6 +550,9 @@ impl<'a> IndexUrls { .iter() .filter(move |index| index.name.as_ref().is_none_or(|name| seen.insert(name))) .find(|index| index.default) + // fork: prefer proxy-resolved default index; + // see astral-sh/uv#6349. + .or_else(|| DEFAULT_INDEX_PROXIED.as_ref()) .or_else(|| Some(&DEFAULT_INDEX)) } }