Skip to content

Vendored uv revert and remove half-revert a project whose sources use dotted keys under [tool.uv]: uv.lock is restored but the sources.<pkg> line stays, so uv sync --locked fails (vendor --revert exits 0) #544

Description

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

Summary

When a uv project declares its existing sources as dotted keys inside [tool.uv] (sources.localpkg = { path = "./localpkg" } or sources.localpkg.path = "./localpkg"), vendored mode writes the patched package's source the same way: sources.six = { path = ".socket/vendor/pypi/<uuid>/six-….whl" }. But the wiring ledger (.socket/vendor/state.json) records the line as six = { path = "…" }. vendor --revert and remove look for that exact line, don't find it, and treat it as third-party drift (vendor_lock_entry_drifted). They still restore uv.lock byte for byte, but they leave the sources.six line in pyproject.toml and keep the artifact directory.

Impact

  • The project is left inconsistent: pyproject.toml still routes six to the vendored wheel, while uv.lock is back to the registry entry. uv sync --locked fails with "The lockfile at uv.lock needs to be updated" (exit 1 on 0.8.17 / 0.12.22, exit 2 on 0.5.31). Frozen CI breaks after an unwind.
  • A plain uv sync re-locks to the vendored wheel, so the "reverted" project silently stays patched.
  • vendor --revert reports status: success and exits 0. remove reports partialFailure. Re-running either never converges: the same drift is reported every time. No user edit happened, so the "undo the drift and re-run" hint can't be followed.

Repro (Linux, main 61cfb9b, real uv 0.8.17)

mkdir -p localpkg/src/localpkg && touch localpkg/src/localpkg/__init__.py
printf '[project]\nname = "localpkg"\nversion = "0.1.0"\n[build-system]\nrequires = ["hatchling"]\nbuild-backend = "hatchling.build"\n' > localpkg/pyproject.toml
cat > pyproject.toml <<'EOF'
[project]
name = "proj"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "idna==3.7", "localpkg"]

[tool.uv]
sources.localpkg = { path = "./localpkg" }
EOF
uv lock && uv sync && cp pyproject.toml pyproject.orig && cp uv.lock uv.lock.orig
# .socket/manifest.json holds a free six 1.16.0 patch (local mock patch API serving the prebuilt wheel)
socket-patch vendor --json $API          # adds: sources.six = { path = ".socket/vendor/pypi/<uuid>/six-1.16.0-py2.py3-none-any.whl" }
uv sync --locked                         # ok, six is patched
socket-patch vendor --revert --json $API # status success, exit 0, events: vendor_lock_entry_drifted, vendor_artifact_kept, vendor_revert_kept
cmp uv.lock uv.lock.orig                 # identical
diff pyproject.orig pyproject.toml       # > sources.six = { path = ".socket/vendor/pypi/<uuid>/six-1.16.0-py2.py3-none-any.whl" }
uv sync --locked                         # error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
uv sync                                  # re-locks to the vendored wheel: six stays patched

The drift message is pyproject.toml fragment for Some("six") changed since vendoring; left untouched, though nothing touched the file. state.json records "new": "six = { path = \".socket/vendor/pypi/<uuid>/six-1.16.0-py2.py3-none-any.whl\" }", and the file holds sources.six = { … }.

socket-patch remove <uuid> gives the same result: partialFailure, the lock is restored, and the sources.six line stays.

Expected vs actual

  • Expected: CLI_CONTRACT / uv-compatibility.md: vendored revert restores the wiring it wrote, byte for byte. The vendor_lock_entry_drifted skip is meant for real third-party edits (pypi_uv.rs revert doc: "revert must never clobber third-party edits"), not for socket-patch's own line. Hosted mode on the same project round-trips byte-identically (checked on this run).
  • Actual: revert misreads its own line as drift and restores only half the project, leaving pyproject and the lock out of sync. It still exits 0.

OS × version

Spelling of existing sources uv 0.5.31 uv 0.8.17 uv 0.12.22
[tool.uv] + sources.localpkg = { path = … } – ❌ –
[tool.uv] + sources.localpkg.path = … ❌ ❌ (reproduced 3×) ❌
[tool.uv.sources] + localpkg = { … } (control) – ✅ byte-identical –
hosted scan → rollback, both dotted spellings (control) – ✅ byte-identical –

The failure is a CLI-side text match, so it doesn't depend on the OS; uv only has to reject the inconsistent result. Not bisected.

Related but distinct: #524 (sub-table spelling leaves an empty header; its "dotted key comes back byte-identical" note covered hosted only) and #474 (real drift from uv add --script). Also, the inline spelling [tool.uv] + sources = { … } is refused up front with pypi_uv_lock_parse_failed: … is not a standard table, so it never reaches this path.

Suspect code

  • crates/socket-patch-core/src/vendor/pypi_uv.rs:602-610: toml_edit inserts the key into a dotted sources table, so it prints sources.six = …, but the record hard-codes format!("{canon_name} = {{ path = … }}").
  • crates/socket-patch-core/src/vendor/pypi_uv.rs:884 (remove_exact_line(&pyproject_text, new) in revert_uv): the exact-line match fails, and the "already converged" probe sees the uuid needle, so the line is reported as drift. Meanwhile the uv.lock records are reverted anyway, which causes the half-revert. One possible fix: record the line toml_edit actually emitted (or match it through the TOML document by key path). And when any pyproject record drifts, don't revert the lock alone.

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