Skip to content

Fix hosted uv pinning dynamic dependencies (#639) - #1709

Open
Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
mainfrom
agent/fix-uv-hosted-dynamic-deps
Open

Mikola Lysenko (mikolalysenko) wants to merge 4 commits into
mainfrom
agent/fix-uv-hosted-dynamic-deps

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #639

What changes for users

In a uv project with dynamic = ["dependencies"] (or
"optional-dependencies"), the build backend supplies the dependency
list (for example setuptools reading requirements.in).
scan --mode hosted used to redirect such a dependency and exit 0. After
that, rollback, remove and the vendored takeover all refused with
"pyproject.toml no longer declares six", so the only way back was
git checkout.

Hosted scan now refuses that package up front with
redirect_uv_metadata_unsupported and writes nothing. Vendored mode
already refuses this shape (pypi_uv_dynamic_dependencies). A package
that a dynamic project only pulls in transitively is still hosted-patched
through override-dependencies, which rollback undoes cleanly.

Root cause

finish_metadata_completion (utils/python_lock.rs) repoints every
root-package requires-dist entry naming the patched package. Upstream
restore (patch/redirect/upstream/uv.rs, declared_clauses) rebuilds
that entry's specifier from the pyproject's static [project]
declaration. A dynamic project has no static declaration, so restore
can't undo the entry. The new refuse_dynamic_root_requirement runs in
prepare_metadata_completion, before anything is mutated. It refuses
when project.dynamic lists dependencies or optional-dependencies and
the lock root's requires-dist names the package. requires-dev comes
from PEP 735 groups, which can't be dynamic, so it isn't checked.

Tests

Issue Test Red on main Green here
#639 patch::redirect::uv_dynamic_dependencies::backend_declared_dependency_is_refused_before_writing (dynamic deps, dynamic optional extra, dynamic deps + static dependency group; LF and CRLF) ✗ panicked: "dependencies": wrote ["pyproject.toml", "uv.lock"] ✓
#639 (no over-refusal) …::transitive_dependency_of_a_dynamic_project_is_still_wired ✓ ✓

Local runs:

  • cargo fmt --all -- --check: ok
  • cargo clippy --workspace --all-features -- -D warnings: ok
  • cargo test -p socket-patch-core --all-features --no-fail-fast: 6727 passed in the lib. The 4 failures are chmod-based tests that can't fail when run as root in this container (copy_tree::relax_loop_must_not_traverse_symlinked_root, vlt_heal::an_unremovable_hidden_lock_keeps_every_store_entry, pypi_poetry::wire_write_failure_…, pypi_requirements::wire_failure_rolls_back_…). They're unrelated to this diff, and CI runs as non-root. All core integration tests pass. The seeded python_lock_rewrite golden is unchanged from main.
  • cargo test -p socket-patch-cli --all-features --lib --bins: 933 passed
  • cargo test -p socket-patch-cli --all-features --test e2e_redirect_uv_build -- --ignored (uv 0.11.32): 9 passed
  • I didn't run the full cargo test --workspace --all-features to completion: linking every CLI e2e binary filled the container disk. CI covers it.
  • No wrapper (npm/, pypi/, gem/) changes are needed: this is core planning logic only.

Code review (/code-review high)

  • Fixed: the first version refused any lock whose root required an undeclared package. That was broader than Hosted uv scan wires a dynamic = ["dependencies"] project, but rollback, remove and the vendored takeover then always refuse ("pyproject.toml no longer declares six") #639 and moved 25 golden chunks. It also let the dynamic shape through when a static dependency group named the package. The guard is now limited to dynamic projects and checks requires-dist directly, and a test covers the group shape.
  • Fixed: the docs no longer claim vendored refuses dynamic = ["optional-dependencies"], and no longer claim a takeover works for dynamic projects. The refusal wording no longer says "unpinned".
  • Not fixed (pre-existing, outside this issue): vendored mode refuses only dynamic = ["dependencies"], not "optional-dependencies". is_root_package matches vendored's lock_has_root_package predicate. It's shared here with finish_metadata_completion, and unifying it with vendored mode would widen this PR.

Follow-ups

  • Projects already hosted-wired before this fix still need git checkout -- pyproject.toml uv.lock to unwind. The pre-scan specifier was never recorded, so it can't be derived.

https://claude.ai/code/session_01AqSzQQq8YyzdQ9f84KtWwQ


Generated by Claude Code

Assisted-by: Claude Code:claude-opus-5-5
A uv project with dynamic = ["dependencies"] gets its requirement list
from the build backend, so pyproject.toml has no static declaration.
Hosted scan still repointed the lock root's requires-dist entry, and
rollback, remove and the vendored takeover then always refused because
they derive the restored specifier from that declaration.

Hosted scan now refuses such a pin up front with
redirect_uv_metadata_unsupported and writes nothing, as vendored mode
does with pypi_uv_dynamic_dependencies. Packages a dynamic project only
pulls in transitively are still wired through override-dependencies,
which unwind cleanly.

Fixes #639

Assisted-by: Claude Code:claude-opus-5-5
The seeded uv rewrite sweep generates locks whose root requires-dist
names packages its pyproject.toml doesn't declare. Those cases are now
refused the same way as a dynamic-dependencies project, so 25 of the
300 chunks change. Every changed chunk is one where a seed hits the new
refusal, and no other chunk moves.

Also document the hosted refusal next to vendored mode's
pypi_uv_dynamic_dependencies in the uv compatibility notes.

Refs #639

Assisted-by: Claude Code:claude-opus-5-5
Review found the first guard both too broad and too narrow. It refused
any lock whose root requires a package the pyproject doesn't declare,
including stale locks unrelated to #639, which moved 25 golden chunks.
And a static dependency group naming the package let a dynamic
project's backend-supplied requires-dist entry through, although
restore matches that entry only against [project] declarations.

Now the refusal fires exactly when project.dynamic lists dependencies
or optional-dependencies and the lock root's requires-dist names the
package. The uv rewrite golden is back to main's, and a test covers
the dependency-group shape.

Refs #639

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko
Mikola Lysenko (mikolalysenko) marked this pull request as ready for review October 24, 2026 07:01
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 85a9881. Configure here.

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 24, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Labeled Ready for review by the burn-down agent.

  • Head: 85a9881d2a60bbaf924c8128f4e5262ba6d88cb3
  • CI: 38/38 non-skipped checks green (50 skipped), no main-wide failures.
  • Bugbot: reviewed this head, no findings.
  • Mergeable against main, no CHANGELOG.md change.

Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief

What it does. A uv project with PEP 621 dynamic = ["dependencies"] (or "optional-dependencies") gets its requirement list from the build backend, so pyproject.toml has nothing to restore the lock root's requires-dist entry from. Hosted mode now refuses, before writing anything, to repoint a package that the lock root lists in requires-dist in such a project (redirect_uv_metadata_unsupported), instead of writing wiring that rollback/remove can't undo (#639). Packages the project only pulls in transitively are still wired through override-dependencies.

Risk: low. Hosted uv path only (the single caller of prepare_metadata_completion is the redirect session), adds a refusal and changes no existing write path; the root-package check is a straight extraction of the existing inline code.

Look here

  • python_lock.rs:656 – refusal runs before any mutation of the lock
  • python_lock.rs:730 – refuse_dynamic_root_requirement: dynamic field check + root requires-dist name match (canonicalized)
  • python_lock.rs:777 – is_root_package extracted, reused at :805
  • mod.rs:28273 – refusal test (LF + CRLF, dynamic optional-deps extra, static dependency group naming the same package)
  • mod.rs:28325 – transitive dep of a dynamic project is still wired

Verified. Read the full diff and the call graph (only hosted redirect reaches the new check; vendored keeps its own pypi_uv_dynamic_dependencies refusal). Tests assert the refusal and no writes, so they fail without the change. CI 38/38 non-skipped green on 85a9881; Cursor Bugbot: no issues; no review threads; CHANGELOG.md untouched.

Changes I made. None.

Open questions (non-blocking). With dynamic = ["optional-dependencies"] only, a package that the static [project].dependencies also lists is refused too, though its non-extra entry would be restorable. That errs on the safe side (refuse, no write); worth narrowing later only if it shows up in practice.

Auto-merge is armed, so approving sends it straight to the merge queue.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

2 participants