Skip to content

Vendored requirements.txt from uv pip compile --universal refuses a marker-split package (six==1.16.0 ; python < 3.12 + six==1.17.0 ; python >= 3.12) as "not pinned to ==1.16.0", while --dry-run previews would_vendor and hosted / vendored pylock handle the same split #928

Description

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

Summary

uv pip compile --universal (and --generate-hashes) writes one requirements line per marker branch whenever a package resolves to different versions on different Pythons or platforms:

six==1.16.0 ; python_full_version < '3.12'
six==1.17.0 ; python_full_version >= '3.12'

The patch is for six@1.16.0. scan --mode vendored refuses the whole package with:

pypi_requirement_not_pinned: requirements.txt: six is not pinned to ==1.16.0; pin it exactly or use agent mode (`scan --mode agent` + `socket-patch apply`) instead

That message is false: the file pins six to ==1.16.0 exactly. find_pin classifies the other branch's ==1.17.0 line as Range, and Range outranks Exact ("a file that names the package ambiguously is never rewritten"). But the marker already separates the two lines, so nothing is ambiguous:

  • Hosted mode rewrites only the 1.16.0 line and leaves 1.17.0 alone (docs: "Hosted requirements select exact ==/=== pins … Other versions remain unchanged").
  • Vendored mode on uv pip compile --universal -o pylock.toml of the same input wires only the six 1.16.0 entry (docs: "Standalone PEP 751 rewriting selects the exact package version").

On top of that, scan --mode vendored --dry-run previews "action": "would_vendor" (exit 0, no warning), and the real run then fails (exit 1, partial_failure). The vendored dry-run preview never runs the requirements preflight (preflight_requirements).

Impact

  • Any vendored project that compiles universal requirements with uv can't vendor a patch for a package that splits by version across markers. This is common for packages that drop old Pythons. The only remedy the CLI offers ("pin it exactly") is impossible, because the file already pins it exactly.
  • CI that gates on --dry-run sees a clean preview, then the real run fails.
  • Nothing is written, so there's no corruption. Other packages in the same run still vendor (partial_failure).

Repro (Linux, uv 0.12.23; same on 0.5.31 / 0.8.17)

printf 'six==1.16.0 ; python_version < "3.12"\nsix==1.17.0 ; python_version >= "3.12"\n' > req.in
uv pip compile -q --universal req.in -o requirements.txt      # optionally --generate-hashes
socket-patch scan --mode vendored --dry-run --json   # exit 0, six: "would_vendor"
socket-patch scan --mode vendored --yes --json       # exit 1, pypi_requirement_not_pinned, file unchanged, no .socket/
socket-patch scan --mode hosted --yes --json         # exit 0, rewrites only the 1.16.0 line

(Run against a local mock patch API serving a patched six 1.16.0 wheel, with --api-url/--patch-server-url/--vendor-url pointing at it.)

The output vendored mode should write already works with uv. Vendoring a file holding only the first line and then appending the 1.17.0 line back gives:

./.socket/vendor/pypi/<uuid>/six-1.16.0-py2.py3-none-any.whl ; python_full_version < '3.12'  # socket-patch vendor: six==1.16.0
six==1.17.0 ; python_full_version >= '3.12'
uv uv pip sync on py3.11 uv pip sync on py3.12
0.5.31 six 1.16.0, patched six 1.17.0 (upstream, correct)
0.12.23 six 1.16.0, patched six 1.17.0 (upstream, correct)

Expected vs actual

  • Expected: vendored requirements behave like hosted requirements and vendored pylock: rewrite every exact ==1.16.0 line (keeping its marker, as single-line markers already are) and leave lines that pin another version unchanged. At minimum, the refusal should say the package is split across versions (not "not pinned"), and --dry-run should preview would_refuse with the same code.
  • Actual: the wet run refuses with a false "not pinned" message (exit 1), and the dry run previews would_vendor (exit 0).

Matrix (Linux)

Input (generated by) uv 0.5.31 uv 0.8.17 uv 0.12.23
--universal requirements.txt, vendored fail fail fail (×2, plus --generate-hashes with a py3.11 .venv)
same, --dry-run would_vendor (wrong) would_vendor (wrong) would_vendor (wrong)
same, hosted – – pass (only the 1.16.0 line rewritten; patched install)
--universal pylock.toml, vendored – – pass (1.16.0 entry only; py3.11 patched, py3.12 upstream; byte-identical revert)
single marker line, vendored pass – pass
six==1.16.0 ; sys_platform=='linux' + six==1.16.0 ; sys_platform=='win32', vendored – – pass (both lines rewritten)

Not OS-specific: the refusal happens in pure planning code before any write. Not bisected: v4.0.0 can't vendor against the harness (no_local_source, see ledger #310).

Suspect code

  • crates/socket-patch-core/src/vendor/pypi_requirements.rs:85-90: scan_pins sets found_range for any pin of the same name to another version (six==1.17.0), regardless of marker.
  • crates/socket-patch-core/src/vendor/pypi_requirements.rs:101-110: find_pin lets Range outrank Exact.
  • crates/socket-patch-core/src/vendor/pypi_requirements.rs:539-548: the "not pinned to =={version}" message.
  • crates/socket-patch-cli/src/commands/scan/vendor_flow.rs:100-146: the dry-run preview has no requirements refusal arm, so it falls through to would_vendor.
  • Unit test find_pin_classifies_every_shape covers six==1.15.0 alone (correctly Range) but not an exact pin next to another version's pin.

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