Skip to content

Vendored uv package in a dependency group: after uv add --dev / uv remove --dev, vendor --revert, remove, rollback and the hosted takeover revert pyproject.toml but keep uv.lock's vendored requires-dev entry, so uv sync --locked fails (exit 0, "success") #821

Description

[agent] Found by the scheduled uv bug-hunt routine (ledger #310).

Summary

The patched package is declared in a dependency group: PEP 735 [dependency-groups] dev = ["six==1.16.0", "attrs>=20"], or the legacy [tool.uv] dev-dependencies. scan --mode vendored rewrites the root package's [package.metadata.requires-dev] line in uv.lock. That fragment covers the whole dev = [ … ] line, with every element (pypi_uv.rs:1224, :1333-1343), not just six's element.

Any later edit to that same group by the user rewrites the whole line, even though six's own element is unchanged. Examples: uv add --dev zipp, uv add --dev idna==3.7, uv remove --dev attrs. After that edit:

  • vendor --revert (also remove / rollback) treats the uv.lock record as drift (vendor_lock_entry_drifted, vendor_artifact_kept, vendor_revert_kept). uv.lock keeps { name = "six", path = ".socket/vendor/…" } in requires-dev and the [[package]] six path source.
  • The pyproject.toml record still matches, so it is reverted: [tool.uv.sources] six = { path = … } is deleted (pypi_uv.rs:968-990).
  • uv sync --locked / uv lock --locked then fail: "The lockfile at uv.lock needs to be updated, but --locked was provided". vendor --revert reports status: "success" and exits 0. remove and rollback exit 1 with partialFailure, after pyproject.toml is already written.

A vendored → hosted takeover (scan --mode hosted) on the same shape is worse. It warns redirect_vendored_revert_failed: "part of its vendored wiring was edited since vendoring, so it is left in place; NOT switched to hosted". But it has already removed six's vendored source from pyproject.toml, so nothing was "left in place". It exits 0 with status: "success", and uv sync --locked fails.

Edits that don't touch the group are fine. uv add idna, uv add --optional x idna and uv add --group lint idna all revert cleanly, as do the same edits when six is in dependencies or an extra (each requires-dist element is its own fragment). Hosted rollback / remove on the same shape after uv add --dev also pass.

This has the same missing pair gate as #806 (a pyproject record reverted while its uv.lock partner is drift-kept). The trigger is different, though, and much more common. #806 needs a user-authored override-dependencies plus a transitive override, and its fragment is [manifest] overrides. Here the user only has to run uv add --dev <anything> on a project whose patched package is a dev dependency. Making the [manifest] overrides match semantic for #806 wouldn't fix this one.

Impact

  • The everyday uv add --dev pytest after vendoring makes every later unwind (vendor --revert, remove, rollback, a hosted takeover) leave a project that uv sync --locked rejects, so CI goes red. vendor --revert and the takeover report success.
  • The ledger entry and .socket/vendor/pypi/<uuid> are kept forever. The suggested remedy ("undo the drift … and re-run vendor --revert") can't be followed: the user never edited six's entry, and uv will keep writing the group line its own way.
  • A plain uv sync silently relocks to the unpatched registry six.

Repro

The mock serves POST /patch/package, the patched six 1.16.0 wheel with SRI sha512, and a /pypi pass-through; it's the same harness as #806 / #788. attrs / zipp / idna resolve from PyPI.

mkdir app && cd app
cat > pyproject.toml <<'EOF'
[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = []

[dependency-groups]
dev = ["six==1.16.0", "attrs>=20"]
EOF
uv lock && uv sync
SP="socket-patch --api-url $MOCK --api-token fake --org acme --patch-server-url $MOCK --vendor-url $MOCK"
$SP scan --mode vendored --yes      # uv.lock requires-dev: dev = [{ name = "attrs", … }, { name = "six", path = ".socket/vendor/…" }]
uv sync --locked                    # ok, six patched
uv add --dev zipp                   # uv rewrites the whole `dev = [...]` requires-dev line (six's element unchanged)
$SP vendor --revert --yes --json    # exit 0, status "success"; events vendor_lock_entry_drifted / vendor_artifact_kept / vendor_revert_kept
grep -c socket/vendor uv.lock       # 1  (requires-dev six still a path source; [[package]] six still path)
grep -c socket/vendor pyproject.toml  # 0  ([tool.uv.sources] six already removed)
uv sync --locked                    # error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.

# Takeover variant: from the state after `uv add --dev zipp`
$SP scan --mode hosted --yes --json # exit 0, status "success", warning redirect_vendored_revert_failed "… left in place …"
grep -c socket/vendor pyproject.toml  # 0, although the warning says the wiring was left in place
uv sync --locked                    # same error

Expected vs actual

  • Expected: docs/testing/uv-compatibility.md:176-178 says: "Script and lock edits are treated as a pair: conflicting changes preserve both files and their recovery state rather than restoring only one side." crates/socket-patch-cli/CLI_CONTRACT.md:714 says drifted fragments "are left alone with a vendor_lock_entry_drifted warning; the drift-kept artifact and entry stay". CLI_CONTRACT.md:164 says, for a takeover: "A takeover revert that leaves vendored wiring in place is refused with redirect_vendored_revert_failed … The ledger entry and artifact are kept, and the package stays vendored and skipped." So either the revert recognizes six's element inside a re-serialized group line (it is byte-identical) and unwinds both files, or it keeps both files wired so uv sync --locked keeps passing. PEP 723 script locks already do the latter: after a relock, both the script and the .py.lock are drift-kept together and uv lock --script --locked passes.
  • Actual: pyproject.toml is reverted and uv.lock is kept, so uv sync --locked fails. vendor --revert and the hosted takeover exit 0 with status: "success", and the takeover's warning misreports the project state.

Matrix

On main 045d7ec. "fail" means uvlock_refs=1 pyproject_refs=0 and uv sync --locked fails. Every Linux cell ran at least twice.

OS uv group spelling edit vendor --revert remove rollback hosted takeover
Linux 0.2.37 legacy dev-dependencies uv add --dev zipp fail – – –
Linux 0.4.30 PEP 735 and legacy uv add --dev zipp fail – – –
Linux 0.5.31 PEP 735 and legacy uv add --dev idna==3.7 fail (exit 0) fail (exit 1) fail (exit 1) –
Linux, macOS, Windows (probe) 0.8.17 / 0.12.23 PEP 735 and legacy uv add --dev idna==3.7 fail (exit 0) fail (exit 1) fail (exit 1) –
Linux 0.12.23 PEP 735 uv add --dev zipp, uv remove --dev attrs fail – – –
Linux 0.8.17 / 0.12.23 PEP 735 uv add --dev zipp – – – fail (exit 0, "left in place")
Linux, macOS, Windows (probe); Linux 0.5.31 0.8.17 / 0.12.23 PEP 735 and legacy uv add --dev idna==3.7, hosted scan then remove / rollback – pass pass –
Linux 0.12.23 six in dependencies or in an extra uv add --dev, uv add --optional, uv add, uv remove pass – – –
Linux 0.12.23 six in dev uv add idna, uv add --optional x idna, uv add --group lint idna (another group) pass – – –
Linux 0.8.17 / 0.12.23 PEP 723 script lock with a user override-dependencies uv add --script, uv lock --script --upgrade-package drift-kept, but both files kept, so --locked passes – – –

First bad version: not bisected. The published v4.0.0 can't vendor against the harness (no_local_source, the pre-v5 vendor flow).

Suspect code

  • crates/socket-patch-core/src/vendor/pypi_uv.rs:1333-1343 (rewrite_root_metadata_entries): the uv_lock_requires_dev record's old / new text is the whole <group> = [ … ] line, so any sibling added to or removed from that group invalidates it. The comment at :1224 says this is deliberate, to tell apart identically pinned groups. Keying on the group name plus the element would do that without capturing the siblings.
  • crates/socket-patch-core/src/vendor/pypi_uv.rs:829-848: the uv_lock_requires_dev arm only matches the exact recorded line (replace_fragment), and the "already converged" check looks for the old line verbatim.
  • crates/socket-patch-core/src/vendor/pypi_uv.rs:968-990: pyproject.toml is written even when a uv.lock record was drift-kept. The unit test revert_warns_and_skips_on_drifted_lock_fragment (:2612-2650) asserts this half-revert ("The pyproject side (undrifted) was still reverted"). That conflicts with the pair rule in uv-compatibility.md:176-178 and is the same gap Vendored uv with a user-authored override-dependencies: after any relock (uv add, uv lock --upgrade-package), vendor --revert / remove revert pyproject.toml but keep the vendored [manifest] overrides entry in uv.lock, so uv sync --locked fails (vendor --revert exits 0) #806 hits.
  • crates/socket-patch-cli/src/commands/scan/hosted.rs:1795-1933: the takeover emits redirect_vendored_revert_failed ("left in place") after revert_uv has already written pyproject.toml.

Probe runs

Related: #806 (same missing pair gate, [manifest] overrides fragment).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions