Skip to content

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

Description

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

Summary

When a uv project declares dynamic = ["dependencies"] (the dependency list comes from the build backend, here setuptools reading requirements.in), scan --mode hosted wires the patch and reports success. It writes [tool.uv] override-dependencies plus a [tool.uv.sources] url, and repoints the lock's requires-dist entry and [manifest] overrides. Real uv installs the patched wheel from that.

Every unwind of that wiring then fails with:

cannot restore pkg:pypi/six@1.16.0 to its upstream registry entry: uv.lock: pyproject.toml no longer declares six, so the lock entry's specifier is not derivable; restore it from version control instead (`git checkout -- uv.lock`)

rollback gives exit 1 / partial_failure, remove gives hosted_revert_failed, and a vendored takeover (scan --mode vendored) gives cannot vendor over the live hosted pin: …. The pyproject.toml and uv.lock stay hosted. The pyproject never declared six statically, so the hosted run created a one-way state its own unwind can't read.

For contrast, vendored mode refuses this project shape up front (pypi_uv_dynamic_dependencies, crates/socket-patch-core/src/vendor/pypi_uv.rs:211) before writing anything. Hosted mode has no equivalent guard.

Impact

A user who hosts a patch on a setuptools/hatch project with dynamic dependencies can't roll back, remove the patch or switch to vendored mode without git checkout. This shape is common: dynamic = ["dependencies"] with requirements.in / requirements.txt files is a standard setuptools pattern. The documented refusal list in CLI_CONTRACT.md "Hosted unwind coverage" doesn't mention dynamic dependencies.

Repro (Linux, uv 0.8.17; a local mock patch API serves the patched six wheel)

cat > pyproject.toml <<'P'
[project]
name = "app"
version = "1.0.0"
requires-python = ">=3.8"
dynamic = ["dependencies"]

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

[tool.setuptools.dynamic]
dependencies = { file = ["requirements.in"] }

[tool.setuptools]
py-modules = []
P
printf 'six==1.16.0\nidna==3.7\n' > requirements.in
uv lock && uv sync
socket-patch scan --mode hosted --yes --json --api-url $MOCK --api-token t --org test-org
#  -> redirected 1, rewrittenFiles [pyproject.toml, uv.lock]
#  pyproject gains: [tool.uv] override-dependencies = ["six==1.16.0"] + [tool.uv.sources] six = { url = … }
uv sync --locked          # installs the hosted wheel; `import six; six.SOCKET_PATCHED` == 1
socket-patch rollback --yes --json --api-url $MOCK --api-token t --org test-org --patch-server-url $MOCK
#  -> exit 1, hosted.failed[0].error = "… pyproject.toml no longer declares six, so the lock entry's specifier is not derivable …"
socket-patch remove pkg:pypi/six@1.16.0 --yes --json …   # -> hosted_revert_failed, same message
socket-patch scan --mode vendored --yes --json …         # -> "cannot vendor over the live hosted pin: …", same cause

(SOCKET_PYPI_JSON_API points at a pypi.org pass-through so the upstream restore can fetch hashes. The lock diff is the normal hosted rewrite: requires-dist { name = "six", specifier = "==1.16.0" } → { name = "six", url = "…" }, plus [manifest] overrides.)

Expected vs actual

  • Expected: CLI_CONTRACT.md ("Hosted unwind coverage", pypi bullet) says rollback / remove / takeover restore each hosted pin from PyPI's JSON API, and that "a transitive override-dependencies entry hosted mode added is removed". Dynamic dependencies aren't in the refusal list. So either the unwind restores the lock (the pre-scan requires-dist specifier is the one the build backend produced; hosted mode itself treated six as an override-wired dependency), or hosted scan refuses / warns up front the way vendored does with pypi_uv_dynamic_dependencies, instead of writing wiring nothing can undo.
  • Actual: the scan succeeds (exit 0) and every unwind refuses. Repeated runs give the same result.

Matrix (Linux; OS-independent TOML logic)

uv hosted scan uv sync --locked after scan rollback remove vendored takeover
0.4.30 redirected fails (documented <0.5.5 override boundary) refused – –
0.5.31 redirected patched refused – –
0.8.17 redirected patched refused (×2) refused refused
0.12.22 redirected patched refused – –

Also checked on draft PR #625 (48bd239, the #606/#473 fix): that build rolls #606 and #473 back byte-identically, but this dynamic case still refuses with the same message, so #625 doesn't cover it.

First bad

v5 upstream restore (no hosted ledger). Not bisected further: main 045d7ec and released 4.0.0 predate it.

Suspect code

  • crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:1053: declared_clauses(meta, declared, &hit.name)?.ok_or_else(|| "… no longer declares …"). For a dynamic project, the pyproject has no static declaration, and the hosted-added override-dependencies entry is only used to drop the [manifest] overrides entry (:1045), not to restore requires-dist.
  • The hosted writer that classifies the dynamic dependency as override-wired (crates/socket-patch-core/src/utils/python_lock.rs metadata completion) has no dynamic check, unlike vendor/pypi_uv.rs:211.

No probe runs: the logic is OS-independent.

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