Skip to content

Hosted uv rollback, remove and vendored takeover refuse when the patched package is declared with different specifiers in dependencies and an extra (or under different markers), although each lock entry keeps its marker #606

Description

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

Summary

scan --mode hosted redirects a uv project where the patched package shows up in [project] with more than one specifier. Examples: dependencies = ["six==1.16.0"] plus [project.optional-dependencies] extra = ["six>=1.15"], two extras with different floors, or one dependencies list with marker-split specifiers. The scan succeeds (exit 0, redirected: 1, uv sync --locked installs the patch). After that, rollback, remove and the hosted → vendored takeover all refuse:

cannot restore pkg:pypi/six@1.16.0 to its upstream registry entry: uv.lock: pyproject.toml declares six with different specifiers; which one each lock entry mirrors is not derivable; restore it from version control instead (`git checkout -- uv.lock`)

The mapping is derivable, though. The hosted rewrite keeps each requires-dist entry's marker (extra == 'extra', python_version < '3.10') and only swaps specifier for url. Each lock entry can therefore be matched to the declaration with the same marker (plus extra == '<name>' for an optional group), which is how uv itself produced it.

Impact

A floor in dependencies with a tighter or looser one in an extra (or an all extra that re-lists packages) is a common pyproject layout. On such a project a hosted patch can be applied but not unwound by any socket-patch command: rollback and remove exit 1, and vendor / scan --mode vendored fail redirect_revert_failed, so the user can't switch modes either. The only way out is git checkout. Vendored mode on the same project vendors and reverts byte-identically.

Repro (main 045d7ec, Linux, uv 0.8.17)

This uses the same local mock patch API as the earlier uv issues (batch / by-package / package / view routes plus a patched six-1.16.0 wheel), --patch-server-url for the mock origin, and SOCKET_PYPI_JSON_API pointing at a pypi.org pass-through.

SP="socket-patch --api-url $MOCK --api-token t --org test-org --patch-server-url $MOCK"
cat > pyproject.toml <<'TOML'
[project]
name = "uvp"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["six==1.16.0", "idna==3.7"]

[project.optional-dependencies]
extra = ["six>=1.15"]
TOML
uv lock && git init -q && git add -A && git commit -qm init
$SP scan --mode hosted --json --yes        # success, redirected 1
rm -rf .venv && uv sync --locked           # installs the patched wheel (SOCKET_PATCHED == 1)
$SP rollback --json --yes                  # exit 1, partial_failure, hosted.failed[] = the error above
$SP remove pkg:pypi/six@1.16.0 --json --yes  # exit 1, hosted_revert_failed
$SP vendor --json                          # exit 1, failed redirect_revert_failed

The hosted lock still carries the markers:

requires-dist = [
    { name = "idna", specifier = "==3.7" },
    { name = "six", url = "http://127.0.0.1:8765/patch/pypi/six/1.16.0/…/six-1.16.0-py2.py3-none-any.whl" },
    { name = "six", marker = "extra == 'extra'", url = "http://127.0.0.1:8765/patch/pypi/six/1.16.0/…/six-1.16.0-py2.py3-none-any.whl" },
]

Other pyprojects that reproduce:

  • dependencies = ["idna==3.7"] with [project.optional-dependencies] a = ["six==1.16.0"], b = ["six>=1.10"]
  • dependencies = ["idna==3.7", "six>=1.10; python_version < \"3.10\"", "six==1.16.0; python_version >= \"3.10\""]

Controls that roll back byte-identically:

  • the same specifier in dependencies and in an extra with a marker (six==1.16.0 and win = ["six==1.16.0; sys_platform == \"win32\""])
  • dependencies = ["six==1.16.0"] plus [dependency-groups] dev = ["six"] (groups are matched per group)
  • vendored vendor → vendor --revert on the failing pyproject

Expected vs actual

  • Expected: CLI_CONTRACT.md, "Hosted unwind coverage" (pypi), restores uv.lock "+ the paired pyproject.toml" with re-derived hashes. Its refusal list for uv covers non-pure wheels, exclude-newer / no-binary / no-build, registry ambiguity and [[distribution]] locks. It doesn't cover several specifiers for one name, and neither does docs/testing/uv-compatibility.md. If this case can't be unwound, the scan should refuse it up front, the way it already refuses marker forks (redirect_uv_project_unsupported), instead of writing a pin that can't be undone.
  • Actual: the scan succeeds. Then rollback, remove and the takeover all exit 1 with "not derivable", and uv.lock / pyproject.toml keep the hosted URL.

OS × uv matrix (Linux sandbox; this is pure TOML logic, so no probe run)

uv deps + extra (a) two extras (c) marker-split deps (e) same specifier + marker (control) vendored revert
0.4.30 ❌ – n/a (scan refuses: uv locks two six versions) – –
0.5.31 ❌ – ❌ – –
0.8.17 ❌ (2 runs; rollback, remove, takeover) ❌ ❌ ✅ ✅
0.12.22 ❌ – ❌ – –

First bad

The hosted upstream restore arrived in v5 (#277). Release 4.0.0 reverted from a ledger fragment, so there's nothing earlier to bisect.

Suspect code

  • crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:801-812: declarations(Declared::Dist) flattens dependencies and every optional-dependency group into one list, losing which extra (and which marker) each specifier belongs to.
  • crates/socket-patch-core/src/patch/redirect/upstream/uv.rs:951-972: declared_clauses then requires every declaration of the name to share one clause list and errors otherwise. Matching on the lock entry's own marker (the declaration marker, and-ed with extra == '<group>' for optional dependencies) would pick the right declaration.

This is related to #473 (include-group), but the root cause is separate: that one is the Declared::Dev arm skipping table members.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions