Skip to content

Commit 4864da5

Browse files
Fix vendored uv export requirements.txt (#1368) (#1390)
* Start fix for #1368 Assisted-by: Claude Code:claude-opus-5-5 * Vendor uv's exported requirements.txt too A uv project often keeps a requirements.txt made by `uv export` for Docker or plain pip installs. Vendored mode wired only uv.lock and named the export as an unpatched install source, so `vendor --check` and `vex` reported contested wiring, and re-running vendor changed nothing, so the suggested remedy looped. Vendoring a uv project now also rewrites an exact registry pin of the package in requirements.txt (or an in-root -r include) to the same committed wheel, as already done beside Pipfile.lock (#612). The ledger entry records both, revert restores both, a superseding patch re-wires both, and a re-run over an already wired uv.lock wires an export made since. Pins the requirements wiring cannot rewrite (a range, a UTF-16 file) stay named by pypi_multiple_lockfiles. The Pipenv and uv paths now share one helper for the sibling wiring and one for its revert. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 * Document uv export co-wiring in the contract The pypi_multiple_lockfiles row now says an exact pin in a uv export requirements.txt is wired with uv.lock, as one beside Pipfile.lock is, and the #1120 CLI test keeps covering the UTF-16 exports that the requirements wiring still refuses. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 * Reuse uv's own wiring-pair constant Assisted-by: Claude Code:claude-opus-5-5 * Harden uv and Pipenv export wiring from review - A symlinked requirements.txt or -r include can't be rewritten in place, so it is no longer treated as wirable. Those projects vendor the lock and name the export as before, instead of failing the run. - A hosted takeover of a uv or Pipenv entry whose export pin sits in a -r include is refused before the revert, as it already is for a requirements-flavor entry, since hosted mode re-pins only the root requirements.txt. - A fresh --dry-run now previews the export wiring. - Rollback after a refused export reuses restore_snapshot, so a file that can't be put back is named in the error. - The flavor matches for the sibling lock and its revert are exhaustive, and the doc comments no longer say Pipenv only. Refs #1368 Assisted-by: Claude Code:claude-opus-5-5 * Keep the export wired when the lock revert drift-keeps - revert_with_sibling reverted the export, then let a drift-kept lock revert (uv's pair gate writes neither file) leave uv.lock wired. The export is now put back on the wheel too, matching the kept entry, so a re-run reverts both once the drift is undone. - snapshot_files recorded an unreadable file as absent, so a rollback could delete it. Only NotFound is recorded as absent now; anything else unreadable is left out and left alone. Refs #1368 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01645q8Fr6CEo5KdPqh3Ne7t --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 34e0c64 commit 4864da5

6 files changed

Lines changed: 721 additions & 176 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1422,7 +1422,7 @@ command's integration tests check its output.
14221422
| `redirect_unconfirmed` | `skipped` (`details.mode: "hosted"`) | hosted `scan` / `get` (v5.0, additive): the patch was granted but no lockfile entry pinning it could be rewritten. The status and exit code are unchanged for now, pending the open hosted exit-policy decision (#704); `--silent` hides the human line. |
14231423
| `lockfile_unreadable` / `lockfile_unparseable` / `patched_ref_invalid` / `patched_ref_unattributable` | run-level `warnings[]` | vex (every form): lockfile-discovery diagnostics — see "Manifest-less VEX (lockfile discovery)". Never flip the exit on their own. |
14241424
| `vex_npm_shrinkwrap_only` | run warning and `failed[].reason` | vex (every form, #899): the patch is wired only in a root `npm-shrinkwrap.json` with no `package-lock.json` twin, which npm >= 12 never reads; no statement until the twin exists. `list` / `rollback` / `remove` still manage the wiring. |
1425-
| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` output) is wired with the lock instead and never named here (#612): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, and a re-run over a project whose `Pipfile.lock` is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. |
1425+
| `vendor_multiple_lockfiles` / `pypi_multiple_lockfiles` | `skipped` (warning) | vendor: a sibling lockfile of another package manager (for PyPI, also a root `requirements.txt` that pins the package beside the wired tool lock) will still install UNPATCHED bytes; names the wired winner + the ignored locks. Beside `Pipfile.lock` or `uv.lock`, an exact registry pin of the package in `requirements.txt` or an in-root `-r` include (`pipenv requirements` or `uv export` output) is wired with the lock instead and never named here (#612, #1368): both files then refer to the committed wheel, the ledger entry records both, every revert restores both, a superseding patch re-wires both, and a re-run over a project whose lock is already wired (a vendor from before, or an export made since) wires the export too (`pypi_requirements_sibling_wired`). A pin the requirements wiring cannot rewrite (a range, extras, an include outside the root) stays a loser. |
14261426
| `vendor_npm_shrinkwrap_only` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #899): the package was wired into `npm-shrinkwrap.json`, the only npm lock (also on an in-sync re-run). npm >= 12 never reads the shrinkwrap and installs the unpatched registry bytes; rename the lock to `package-lock.json` (or commit a copy under that name) and re-run. |
14271427
| `vendor_workspace_member_skipped` | `skipped` (warning) | vendor / scan / get `--mode vendored` (npm, #688): a lock entry with the package's `name@version` is the project's own source (a workspace member or a `file:` directory), so it is left alone while the lock's registry copies are vendored; patch that source directly if it needs the fix. When it is the only instance, vendoring refuses instead. |
14281428
| `vendor_npm_allow_file` | `skipped` (warning, printed as `Warning (vendor_npm_allow_file)`) | vendor / scan / get `--mode vendored` (package-lock, #969): the effective npm `allow-file` setting (env > project `.npmrc` > user > global > builtin) is not `all`, so npm >= 11.14 refuses the vendored `file:` tarball (EALLOWFILE) on install. The setting is respected, never rewritten; the detail names where it comes from and the remedy (`allow-file=all`). `vendor --check` fails the entry for the same reason. |

‎crates/socket-patch-cli/tests/mode_migration_pypi.rs‎

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1667,15 +1667,71 @@ async fn vendor_check_fails_after_hatch_dependency_reset() {
16671667
assert_check_catches_relock(&root, files);
16681668
}
16691669

1670+
/// #1368: a `uv export` requirements.txt beside `uv.lock` is an install
1671+
/// source of its own (Docker / plain pip). `vendor` wires its exact pin to
1672+
/// the same committed wheel as uv.lock, `vendor --check` passes with no
1673+
/// contest, a re-run is settled, and `vendor --revert` restores all three
1674+
/// files byte for byte.
1675+
#[tokio::test]
1676+
async fn uv_requirements_export_is_wired_with_the_lock() {
1677+
let export = "six==1.16.0 \\\n --hash=sha256:WHEEL_SHA\n".replace("WHEEL_SHA", WHEEL_SHA);
1678+
let (_tmp, root) = project();
1679+
let files = stage_uv(&root);
1680+
std::fs::write(root.join("requirements.txt"), &export).unwrap();
1681+
let originals: Vec<(&str, Vec<u8>)> = files
1682+
.iter()
1683+
.chain(&["requirements.txt"])
1684+
.map(|f| (*f, std::fs::read(root.join(f)).unwrap()))
1685+
.collect();
1686+
stage_manifest(&root);
1687+
let needle = format!(".socket/vendor/pypi/{UUID}/");
1688+
1689+
let (code, env) = run_cli(&root, &["vendor"], &[]);
1690+
assert_eq!(code, 0, "{env:#}");
1691+
for file in ["uv.lock", "requirements.txt"] {
1692+
assert!(
1693+
std::fs::read_to_string(root.join(file))
1694+
.unwrap()
1695+
.contains(&needle),
1696+
"{file} is wired: {env:#}"
1697+
);
1698+
}
1699+
assert!(
1700+
!env.to_string().contains("pypi_multiple_lockfiles"),
1701+
"the export is wired, not an unpatched loser: {env:#}"
1702+
);
1703+
1704+
let (code, env) = run_cli(&root, &["vendor", "--check"], &[]);
1705+
assert_eq!(code, 0, "both install sources are wired: {env:#}");
1706+
1707+
// Re-running is settled: nothing rewritten.
1708+
let wired = std::fs::read(root.join("requirements.txt")).unwrap();
1709+
let (code, env) = run_cli(&root, &["vendor"], &[]);
1710+
assert_eq!(code, 0, "{env:#}");
1711+
assert_eq!(std::fs::read(root.join("requirements.txt")).unwrap(), wired);
1712+
1713+
let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]);
1714+
assert_eq!(code, 0, "{env:#}");
1715+
for (file, bytes) in &originals {
1716+
assert_eq!(
1717+
&std::fs::read(root.join(file)).unwrap(),
1718+
bytes,
1719+
"{file} is restored"
1720+
);
1721+
}
1722+
assert!(!root.join(format!(".socket/vendor/pypi/{UUID}")).exists());
1723+
}
1724+
16701725
/// #1120: a `requirements.txt` exported beside `uv.lock` pins the
1671-
/// package from PyPI, so the uv-only wiring leaves installs from it
1672-
/// unpatched. `uv export > requirements.txt` in Windows PowerShell 5.1
1673-
/// writes it as UTF-16 with a byte-order mark, which pip and uv read like
1674-
/// its UTF-8 twin, so `vendor` must name it (`pypi_multiple_lockfiles`)
1675-
/// and `vendor --check` must fail on the contested wiring in every
1676-
/// encoding, not pass as if the file were absent.
1726+
/// package from PyPI. `uv export > requirements.txt` in Windows
1727+
/// PowerShell 5.1 writes it as UTF-16 with a byte-order mark, which pip
1728+
/// and uv read like its UTF-8 twin but the requirements wiring refuses to
1729+
/// rewrite, so the uv-only wiring leaves installs from it unpatched:
1730+
/// `vendor` must name it (`pypi_multiple_lockfiles`) and `vendor --check`
1731+
/// must fail on the contested wiring, not pass as if the file were absent.
1732+
/// (Its UTF-8 twin is wired with the lock, #1368.)
16771733
#[tokio::test]
1678-
async fn uv_requirements_export_contests_the_wiring_in_any_encoding() {
1734+
async fn uv_requirements_export_contests_the_wiring_in_utf16() {
16791735
let text = "six==1.16.0\r\n";
16801736
let le: Vec<u8> = [0xFF, 0xFE]
16811737
.into_iter()
@@ -1685,11 +1741,7 @@ async fn uv_requirements_export_contests_the_wiring_in_any_encoding() {
16851741
.into_iter()
16861742
.chain(text.encode_utf16().flat_map(u16::to_be_bytes))
16871743
.collect();
1688-
for (case, bytes) in [
1689-
("utf-8", text.as_bytes().to_vec()),
1690-
("utf-16 le", le),
1691-
("utf-16 be", be),
1692-
] {
1744+
for (case, bytes) in [("utf-16 le", le), ("utf-16 be", be)] {
16931745
let (_tmp, root) = project();
16941746
stage_uv(&root);
16951747
std::fs::write(root.join("requirements.txt"), &bytes).unwrap();

‎crates/socket-patch-core/src/patch/redirect/pypi_takeover.rs‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub async fn preflight_pypi_takeover(
2828
if entry.ecosystem != "pypi" {
2929
return Ok(());
3030
}
31+
preflight_sibling_requirements_takeover(entry)?;
3132
match entry.flavor.as_deref() {
3233
Some("requirements") => preflight_requirements_takeover(entry),
3334
Some("uv") => preflight_uv_takeover(entry),
@@ -36,6 +37,31 @@ pub async fn preflight_pypi_takeover(
3637
}
3738
}
3839

40+
/// A Pipenv or uv entry also carries the `requirements_line` records of
41+
/// the exported requirements pin wired with its lock (#612, #1368). After
42+
/// the revert, the hosted requirements rewriter re-pins only the root
43+
/// requirements.txt, so a line wired in a `-r` include is refused exactly
44+
/// as for a requirements-flavor entry.
45+
fn preflight_sibling_requirements_takeover(entry: &VendorEntry) -> Result<(), RewriteWarning> {
46+
if !matches!(entry.flavor.as_deref(), Some("uv" | "pipenv")) {
47+
return Ok(());
48+
}
49+
let lines: Vec<_> = entry
50+
.wiring
51+
.iter()
52+
.filter(|r| r.kind == "requirements_line")
53+
.cloned()
54+
.collect();
55+
if lines.is_empty() {
56+
return Ok(());
57+
}
58+
preflight_requirements_takeover(&VendorEntry {
59+
flavor: Some("requirements".into()),
60+
wiring: lines,
61+
..entry.clone()
62+
})
63+
}
64+
3965
/// Vendored uv pins the lock's `[[package]]` unit to the patch's version
4066
/// even when the lock resolved another one (#723). The revert restores the
4167
/// recorded pre-vendor unit, and the hosted uv rewriter only pins an entry
@@ -178,6 +204,46 @@ mod tests {
178204
assert!(detail.contains("EVERY vendored package"), "{detail}");
179205
}
180206

207+
fn requirements_line(file: &str) -> WiringRecord {
208+
WiringRecord {
209+
file: file.into(),
210+
kind: "requirements_line".into(),
211+
action: WiringAction::Rewritten,
212+
key: Some(format!("{file}:1")),
213+
original: None,
214+
new: None,
215+
}
216+
}
217+
218+
/// #1368 / #612: the exported pin wired with a uv or Pipenv lock is
219+
/// re-pinned by the hosted requirements rewriter, which reaches only
220+
/// the root requirements.txt; one wired in a `-r` include is refused
221+
/// before the revert, a root one is admitted.
222+
#[tokio::test]
223+
async fn sibling_export_in_an_include_is_refused() {
224+
let root = tempfile::tempdir().unwrap();
225+
for (flavor, lock) in [
226+
("uv", uv_package("1.16.0")),
227+
("pipenv", uv_package("1.16.0")),
228+
] {
229+
let e = entry(
230+
flavor,
231+
vec![lock.clone(), requirements_line("requirements.txt")],
232+
);
233+
assert!(
234+
preflight_pypi_takeover(root.path(), &e).await.is_ok(),
235+
"{flavor}"
236+
);
237+
let e = entry(flavor, vec![lock, requirements_line("req/base.txt")]);
238+
let err = preflight_pypi_takeover(root.path(), &e).await.unwrap_err();
239+
assert_eq!(
240+
err.code, "redirect_requirements_takeover_unreachable",
241+
"{flavor}"
242+
);
243+
assert!(err.detail.contains("req/base.txt"), "{}", err.detail);
244+
}
245+
}
246+
181247
#[test]
182248
fn uv_entry_at_the_patch_version_is_admitted() {
183249
assert!(preflight_uv_takeover(&entry("uv", vec![uv_package("1.16.0")])).is_ok());

0 commit comments

Comments
 (0)