From 08af3c1d67a1984d52d8fdbeed09e98a004820c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 06:23:08 +0000 Subject: [PATCH 1/4] Start fix for #639 Assisted-by: Claude Code:claude-opus-5-5 From 3a13577463b747693696a62bff54a0e1e490d45c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 06:31:38 +0000 Subject: [PATCH 2/4] Refuse hosted uv pins dynamic deps can't restore A uv project with dynamic = ["dependencies"] gets its requirement list from the build backend, so pyproject.toml has no static declaration. Hosted scan still repointed the lock root's requires-dist entry, and rollback, remove and the vendored takeover then always refused because they derive the restored specifier from that declaration. Hosted scan now refuses such a pin up front with redirect_uv_metadata_unsupported and writes nothing, as vendored mode does with pypi_uv_dynamic_dependencies. Packages a dynamic project only pulls in transitively are still wired through override-dependencies, which unwind cleanly. Fixes #639 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 136 ++++++++++++++++++ .../src/utils/python_lock.rs | 121 ++++++++++++++-- 2 files changed, 248 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index f31ba9fc3..4b9c9d02e 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -28211,3 +28211,139 @@ mod uv_old_release_advisories { ); } } + +/// #639: a PEP 621 `dynamic = ["dependencies"]` project gets its dependency +/// list from the build backend, so pyproject.toml declares nothing uv's +/// root `requires-dist` can be restored from. Hosted mode must refuse to +/// repoint such an entry (vendored refuses the shape with +/// `pypi_uv_dynamic_dependencies`) rather than write wiring that rollback, +/// remove and the vendored takeover can never undo. +#[cfg(test)] +mod uv_dynamic_dependencies { + use super::*; + + const URL: &str = "https://patch.test/six-1.16.0-py2.py3-none-any.whl"; + const SIX: &str = "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = { registry = \"https://pypi.org/simple\" }\nwheels = [{ url = \"https://files.pythonhosted.org/packages/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254\" }]\n"; + const ISODATE: &str = "[[package]]\nname = \"isodate\"\nversion = \"0.6.1\"\nsource = { registry = \"https://pypi.org/simple\" }\ndependencies = [{ name = \"six\" }]\nwheels = [{ url = \"https://files.pythonhosted.org/packages/isodate-0.6.1-py2.py3-none-any.whl\", hash = \"sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96\" }]\n"; + + fn six() -> DepOverride { + DepOverride { + ecosystem: "pypi".into(), + name: "six".into(), + namespace: None, + version: "1.16.0".into(), + token: String::new(), + patch_uuid: "11111111-1111-4111-8111-111111111111".into(), + artifact_url: URL.into(), + registry_override: None, + integrity: Integrity { + sha256: Some("c".repeat(64)), + ..Default::default() + }, + } + } + + /// The issue's setuptools project: `dynamic` names `dynamic_fields`, + /// and the backend reads the list from requirements.in. + fn project(dynamic_fields: &str) -> String { + format!( + "[project]\nname = \"app\"\nversion = \"1.0.0\"\nrequires-python = \">=3.8\"\ndynamic = [{dynamic_fields}]\n\n[build-system]\nrequires = [\"setuptools>=61\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.setuptools.dynamic]\ndependencies = {{ file = [\"requirements.in\"] }}\n" + ) + } + + fn lock(app_deps: &str, requires: &str, packages: &[&str]) -> String { + format!( + "version = 1\nrevision = 3\nrequires-python = \">=3.8\"\n\n[[package]]\nname = \"app\"\nversion = \"1.0.0\"\nsource = {{ editable = \".\" }}\ndependencies = [{app_deps}]\n\n[package.metadata]\nrequires-dist = [{requires}]\n\n{}", + packages.join("\n") + ) + } + + fn files(pyproject: String, uv_lock: String) -> BTreeMap { + BTreeMap::from([ + ("pyproject.toml".to_string(), pyproject), + ("uv.lock".to_string(), uv_lock), + ]) + } + + /// The backend-supplied `six==1.16.0` is refused for every newline + /// style and for a dynamic optional-dependencies extra: nothing is + /// written, and the warning names the dynamic declaration. + #[test] + fn backend_declared_dependency_is_refused_before_writing() { + let cases = [ + ( + "\"dependencies\"", + "{ name = \"six\", specifier = \"==1.16.0\" }", + ), + ( + "\"optional-dependencies\"", + "{ name = \"six\", marker = \"extra == 'compat'\", specifier = \"==1.16.0\" }", + ), + ]; + for (dynamic, requires) in cases { + for newline in ["\n", "\r\n"] { + let input = files( + project(dynamic).replace('\n', newline), + lock("{ name = \"six\" }", requires, &[SIX]).replace('\n', newline), + ); + let result = rewrite_registry_redirect(&input, &[six()]); + assert!( + result.files.is_empty(), + "{dynamic}: wrote {:?}", + result.files.keys() + ); + assert!( + result.refused_python_lock_uuids.contains(&six().patch_uuid) + && !result + .confirmed_python_lock_uuids + .contains(&six().patch_uuid), + "{dynamic}: {:?}", + result.warnings + ); + let warning = result + .warnings + .iter() + .find(|w| w.code == "redirect_uv_metadata_unsupported") + .unwrap_or_else(|| panic!("{dynamic}: {:?}", result.warnings)); + assert!( + warning.detail.contains("six") && warning.detail.contains("dynamic"), + "{}", + warning.detail + ); + } + } + } + + /// A package the backend's list only pulls in transitively is not in + /// the root `requires-dist`, so its override wiring stays restorable + /// and the dynamic project is still hosted-patched. + #[test] + fn transitive_dependency_of_a_dynamic_project_is_still_wired() { + let input = files( + project("\"dependencies\""), + lock( + "{ name = \"isodate\" }", + "{ name = \"isodate\", specifier = \"==0.6.1\" }", + &[ISODATE, SIX], + ), + ); + let result = rewrite_registry_redirect(&input, &[six()]); + assert!( + result + .confirmed_python_lock_uuids + .contains(&six().patch_uuid), + "{:?}", + result.warnings + ); + assert!( + result.files["pyproject.toml"].contains("override-dependencies"), + "{}", + result.files["pyproject.toml"] + ); + assert!( + result.files["uv.lock"].contains(URL), + "{}", + result.files["uv.lock"] + ); + } +} diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs index 72ffe1254..98a814ff5 100644 --- a/crates/socket-patch-core/src/utils/python_lock.rs +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -653,6 +653,7 @@ fn prepare_metadata_completion( let project: DocumentMut = project .parse() .map_err(|error| format!("invalid pyproject.toml: {error}"))?; + refuse_undeclared_root_requirement(document, &project, &name)?; let overridden = project .get("tool") .and_then(Item::as_table_like) @@ -718,6 +719,116 @@ fn prepare_metadata_completion( })) } +/// Refuse when the lock's root package requires `name` (in +/// `requires-dist` or `requires-dev`) but `project` declares no `name` +/// requirement statically, as with PEP 621 `dynamic = ["dependencies"]` +/// where the build backend supplies the list (#639). Upstream restore +/// derives the entry's specifier from that declaration, so a repointed +/// entry could never be unwound by rollback, remove or a vendored takeover. +fn refuse_undeclared_root_requirement( + document: &DocumentMut, + project: &DocumentMut, + name: &str, +) -> Result<(), String> { + let names = |item: Option<&Item>| -> Vec { + item.and_then(Item::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(|spec| canonicalize_pypi_name(crate::vendor::common::pep508_name(spec))) + .collect() + }; + let legacy_dev = project + .get("tool") + .and_then(|tool| tool.get("uv")) + .and_then(|uv| uv.get("dev-dependencies")); + let declared = crate::vendor::common::pyproject_dependency_specs(project) + .into_iter() + .any(|(_, spec)| canonicalize_pypi_name(crate::vendor::common::pep508_name(spec)) == name) + || names(legacy_dev).iter().any(|n| n == name); + if declared { + return Ok(()); + } + let required = document + .get("package") + .and_then(Item::as_array_of_tables) + .into_iter() + .flatten() + .filter(|package| is_root_package(package)) + .filter_map(|package| package.get("metadata").and_then(Item::as_table_like)) + .any(|metadata| { + ["requires-dist", "requires-dev"].into_iter().any(|key| { + requirement_names(metadata.get(key)) + .iter() + .any(|n| n == name) + }) + }); + if !required { + return Ok(()); + } + let dynamic: Vec<&str> = project + .get("project") + .and_then(|p| p.get("dynamic")) + .and_then(Item::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|field| ["dependencies", "optional-dependencies"].contains(field)) + .collect(); + let source = if dynamic.is_empty() { + "pyproject.toml declares no such requirement".to_string() + } else { + format!( + "pyproject.toml declares dynamic = [{}], so the build backend supplies it", + dynamic + .iter() + .map(|field| format!("\"{field}\"")) + .collect::>() + .join(", ") + ) + }; + Err(format!( + "the lock's root package requires {name}, but {source}; hosted mode leaves it \ + unpinned because rollback could not restore the lock's requirement entry" + )) +} + +/// Whether `package` is the lock's root project (`source = { virtual = "." }` +/// or `{ editable = "." }`). +fn is_root_package(package: &Table) -> bool { + package + .get("source") + .and_then(Item::as_table_like) + .is_some_and(|source| { + ["virtual", "editable"] + .into_iter() + .any(|key| source.get(key).and_then(Item::as_str) == Some(".")) + }) +} + +/// The canonical names of a lock `requires-dist` array, or of every group +/// of a `requires-dev` table. +fn requirement_names(item: Option<&Item>) -> Vec { + let Some(item) = item else { + return Vec::new(); + }; + if let Some(array) = item.as_array() { + array + .iter() + .filter_map(Value::as_inline_table) + .filter_map(|requirement| requirement.get("name").and_then(Value::as_str)) + .map(canonicalize_pypi_name) + .collect() + } else if let Some(table) = item.as_table_like() { + table + .iter() + .flat_map(|(_, item)| requirement_names(Some(item))) + .collect() + } else { + Vec::new() + } +} + fn finish_metadata_completion( document: &mut DocumentMut, completion: MetadataCompletion, @@ -735,15 +846,7 @@ fn finish_metadata_completion( .and_then(Item::as_array_of_tables_mut) .expect("package collection checked"); for package in packages.iter_mut() { - let root = package - .get("source") - .and_then(Item::as_table_like) - .is_some_and(|source| { - ["virtual", "editable"] - .into_iter() - .any(|key| source.get(key).and_then(Item::as_str) == Some(".")) - }); - if root { + if is_root_package(package) { if let Some(metadata) = package .get_mut("metadata") .and_then(Item::as_table_like_mut) From 748a00efbce63fed7a0c0f6dc4466a57ac235685 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 06:48:37 +0000 Subject: [PATCH 3/4] Re-bless uv rewrite golden for dynamic-dep refusal The seeded uv rewrite sweep generates locks whose root requires-dist names packages its pyproject.toml doesn't declare. Those cases are now refused the same way as a dynamic-dependencies project, so 25 of the 300 chunks change. Every changed chunk is one where a seed hits the new refusal, and no other chunk moves. Also document the hosted refusal next to vendored mode's pypi_uv_dynamic_dependencies in the uv compatibility notes. Refs #639 Assisted-by: Claude Code:claude-opus-5-5 --- .../equivalence/python_lock_rewrite.golden | 50 +++++++++---------- docs/testing/uv-compatibility.md | 10 ++++ 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/crates/socket-patch-core/tests/equivalence/python_lock_rewrite.golden b/crates/socket-patch-core/tests/equivalence/python_lock_rewrite.golden index 553036d9e..693b40d88 100644 --- a/crates/socket-patch-core/tests/equivalence/python_lock_rewrite.golden +++ b/crates/socket-patch-core/tests/equivalence/python_lock_rewrite.golden @@ -11,13 +11,13 @@ 40-44 cf5611d067871c91 a654da53f2bf68f3 45-49 04f0e41a9e302f59 703a8fae640714d8 50-54 03d58da62810b2e4 b8a911ff40818fe5 -55-59 daf9f41b5decbf2c 4d5c0a6a8091972c +55-59 daf9f41b5decbf2c aebe0024c5fcb1a6 60-64 58413f6f2f266d0d e269d6e79181da6a 65-69 cd4f77526796ec70 ffec6efe319265a7 70-74 3befe3b6747c7c5d e8bf2fc66a89b4c0 75-79 f704740390513318 42df63da4590a254 -80-84 6bb2704eadd90bbb d31e655a72986955 -85-89 2b41ca85da12924d 776f725f1d547ae5 +80-84 6bb2704eadd90bbb a0c7472747f1a138 +85-89 2b41ca85da12924d 382e97dedcaf5bdf 90-94 b4f4d21d57b4e3e6 0f3435d61d7a36c1 95-99 1fcae5cb34b3557b 4eb4e16e7ffeb917 100-104 22d4b081083ec3cf 59d0596979c806fd @@ -36,7 +36,7 @@ 165-169 8f16946d73613e52 77dc4a7d5887d3c0 170-174 72c1e1cb26500151 57495ab2d9e9c573 175-179 4333d230209fdfdd ad0aa98f28ad155a -180-184 70af4e6d0a9804eb 5ff214be13409158 +180-184 70af4e6d0a9804eb 0ca949c1554daabd 185-189 6073f94564a728c8 42f8708863320edf 190-194 0976869c3d0f4aeb 03f687f9a1ef3754 195-199 6e26da934df4169e 1cfc9815b13e525a @@ -62,15 +62,15 @@ 295-299 99fb36209f586bca e891c6658cee191a 300-304 5240085dd621df82 458bf378d060d912 305-309 616cfc3aae79162c 9773426cddea96c8 -310-314 a386fe7bc32fc73e 012e854d302744b3 +310-314 a386fe7bc32fc73e c53b652fc04125a7 315-319 5d7084c26ae0f3f2 d1ce684178d4a81b 320-324 e0a4ea3772d1038a ade4bdb1bc7842ea 325-329 0cb388111d9a1b86 839131fd61828cf2 330-334 d1c41d95d297d164 35fdd17d28531ad6 335-339 2d6db0a042990332 61f544ab33b2cfd6 340-344 acfe94db4b59c187 7daa8a861d019f20 -345-349 f443e9c02e4c493c 4770cd61363c20c9 -350-354 6b8189fa7c7eb359 f6a03545fbeec491 +345-349 f443e9c02e4c493c e04efc09bfd18439 +350-354 6b8189fa7c7eb359 15a98d47f150e438 355-359 48640c9838b42488 5aaab0260d513d16 360-364 86adc1124beaf310 71f8b11ce3847ea3 365-369 c75c9390e1ec4dc0 9ed2778ab5ff262d @@ -79,21 +79,21 @@ 380-384 bae2ef0777128159 394248e293242674 385-389 a72fe500fd93b54b a48cd849bf320b7b 390-394 1bda7f0386bcb4b2 8a4c74b37267fb35 -395-399 662e2ce03cc70051 5fed6d12d2490ddc +395-399 662e2ce03cc70051 f1cb0c0264f4483b 400-404 27e91e5f8d9d0500 06b82bdf156dad07 405-409 d97cfed3b6019a84 4c43f9bf02b1c878 410-414 9fc1135cea15545b 931a5c267fbea391 415-419 ec0440f6f542cdbe 78e26f5a0c86f7bd 420-424 820d32ca97f38bf1 6e8015b8a4a26459 -425-429 a187a84aad4ddd82 f5d0b8ab277a2913 +425-429 a187a84aad4ddd82 039785b1e46ad44e 430-434 63cab5602f883c2b e5605a0a65a3449f 435-439 6b51186155d6b708 9f185e6f0051e7a7 440-444 d3c75dad29457c53 e274e66edc596cb2 445-449 5b312af32839186f 8fbf6337f2932e00 450-454 867d9e5d19789b27 6b144dbc0dd1e95a -455-459 845c435d60d254cd 0e0499e922caa21f +455-459 845c435d60d254cd 88afb9584e4bce77 460-464 fe6ab0fb975606e6 ec3f3ab76d25c8de -465-469 cf94bc0890135d7d fd4b8e2927bebd03 +465-469 cf94bc0890135d7d d1e5a41413b44aac 470-474 5e2dbff33d066336 aca35a27b863300a 475-479 0479e5e66eeb0b35 b85602f8b33a3a40 480-484 728477d2aff05013 0ab363fd26c2e144 @@ -104,7 +104,7 @@ 505-509 45a1e1c9fbf16b34 8d22ee5d9c4a28c1 510-514 818d16cd8b3bec8a ade0d01f88cdff6f 515-519 c7422c418ed92126 4a93055b7f3ee09a -520-524 a3e57516f4ee56fe 2025a941c447fa1e +520-524 a3e57516f4ee56fe acac4950f0d5e345 525-529 554002fd18293950 43a791951252a9e9 530-534 70f5bc4e1811144d 2d5fece8c7f51bcc 535-539 3bc9d29e9245a772 772970ae662a3597 @@ -116,10 +116,10 @@ 565-569 0b1e0cc286d8e2db 9c09b80a57757558 570-574 0eca795339181b14 adb66e3ab9f7f68c 575-579 5ca964a495b608c9 dccc25c013660626 -580-584 c526bfcdbdb19c13 dd691349d322d087 +580-584 c526bfcdbdb19c13 9eb0cde44dbca274 585-589 c0f55f7b738667c8 f46682e9b36c7702 590-594 9a57465f007322f6 51adc74f13726b01 -595-599 60d49df8b1a1ed60 ea2da15f06594617 +595-599 60d49df8b1a1ed60 acb74f75dbf6b585 600-604 eb8f971fa2c853f1 77603a90b9c742bf 605-609 b9a0d832ab3703a4 f96c87efc5d481bc 610-614 b7cd2826e9ed4bad 1dae82316d44d372 @@ -145,7 +145,7 @@ 710-714 0096b81bcf606d08 09c15a61fa9b7174 715-719 afbe3acb8a936112 df6e9deb669be132 720-724 e212d53b068c9367 aadfdb29a8bcc99e -725-729 b532d08326d3a1c1 ab364c6f8a842555 +725-729 b532d08326d3a1c1 82e6dbe6e55e2780 730-734 fb89f0ab21e41184 8a330cf19706a811 735-739 7f21245b19048f6d 61a95bc86e9de20b 740-744 f5afc189901e25a7 e2e144556544337d @@ -153,7 +153,7 @@ 750-754 a2da5259aa3dc6bb 23d530751e7cdde1 755-759 d5fed3964610e80b 659a2c1a04c6442e 760-764 eb74f4ed1d526a82 959dc5c757d52c8d -765-769 446fbe655c39474a 65dbfbc0e481e7a1 +765-769 446fbe655c39474a 19fbf839de97d395 770-774 3ed2f6336ae1d8b4 577ee0765be7700c 775-779 0c443aac3fea26b0 d014fd768c4bf4c7 780-784 fff5de443b6d9df3 aea7405cafc4c5b7 @@ -175,14 +175,14 @@ 860-864 e0dfd4ee3b9b9115 9bccc43b5c137ed6 865-869 3e913b7411e46c27 4306522989316626 870-874 fdd6c8ad6bcba04e 32ad3ee5e1c12d8e -875-879 daeef0f506171b5e f143eb2ccb94a6a1 +875-879 daeef0f506171b5e 4a070937908d6fdd 880-884 a19a9111ca539727 47d9a8a193744011 885-889 4964fd890aa0994b 4af19ffda897291e 890-894 50069923d0e0ff0c ccd6d0e66f60528e 895-899 45a6202dc931bd35 ddc86e63ebde42c6 900-904 3e4b61b6dfc3922b 83e3365600063210 905-909 c91a83be7091fe1c d13f31e52c02bcc7 -910-914 8418b5e59791bc82 14a923d5c8db097d +910-914 8418b5e59791bc82 8231ec3f06d991b7 915-919 5a056b11f328365b e87365882f0beeba 920-924 34137d01c0b33616 25eb24321c54f780 925-929 2bfe9d227e06cc61 36a2a2804e7199c0 @@ -199,11 +199,11 @@ 980-984 2dfdeb1d730d437f a0d94833e09775ea 985-989 c0ce894b61174b60 3aab2781acd07afb 990-994 a4c96d873de9cde3 eb3b3c6230974260 -995-999 3b004642590efb27 de61743f7afd0468 +995-999 3b004642590efb27 916341a8ce9e15a3 1000-1004 1e11b2228a87e2cd ddbbfeb2ef0cb9a6 1005-1009 863857d4840e1755 355fdf9286f05798 1010-1014 f32eb531a0227019 b9b5a3a04eecda96 -1015-1019 5df98b07a3f6fa22 0d2d4cda47b6f7bd +1015-1019 5df98b07a3f6fa22 71987a42c890a925 1020-1024 7788148d1188683a 186dad916be4a966 1025-1029 2a42ee2c74e792bf 9cae632550c12f9a 1030-1034 8615096392ecaf53 cebb5c90b23dccac @@ -231,14 +231,14 @@ 1140-1144 643000c4068a73dc 2e95ee9e1f3703cc 1145-1149 a20dbdc962bf496d 0546e3d1c9df5cd0 1150-1154 a8ff0a76fb490212 86381bb67dd20a29 -1155-1159 265ef1e7e4d619b0 70f4ca757f3efda6 +1155-1159 265ef1e7e4d619b0 672c2224c448ccb8 1160-1164 fd031848e1246ce5 be60639cbdf765b0 -1165-1169 46392bbe676ae180 473a5c2505c04570 +1165-1169 46392bbe676ae180 77381760aacc53c9 1170-1174 9ed9aa2666accf82 812866a9c80517d4 1175-1179 42ba5427c32b6a50 f74c503679d95c63 1180-1184 ec53e605cb4007b0 c3e34158cb147df0 1185-1189 a9bca1c3f2f28d14 f9858e5a8f8302ee -1190-1194 99c3c937889a535d 8acaa3ca71314677 +1190-1194 99c3c937889a535d 0457b521eda5ef48 1195-1199 516dc4d7731053d5 b805922b219f6337 1200-1204 615780e5c51bd99d 3f58dc0ca1eb2952 1205-1209 2ba50ff0fb6738c8 8e99678812722afc @@ -255,7 +255,7 @@ 1260-1264 a90cc7dbee7863ee a4f79bdb0ab09720 1265-1269 9601afdc614c3378 07d5b302406eee8d 1270-1274 e4027cab6820ab9c f7909eaf9d4be339 -1275-1279 52f35f77c626c7a9 1777115e4bd07213 +1275-1279 52f35f77c626c7a9 d57b6e9bb82d2843 1280-1284 54db489225a8498f a23b0bca17624b1c 1285-1289 503688b417683963 e173db817b5bd10f 1290-1294 0ec750caf91a885a ba83a3f344246704 @@ -295,7 +295,7 @@ 1460-1464 ce73f815cae74496 4fb2814133a59555 1465-1469 1695babf780735fd cd1492eabdb7ef4a 1470-1474 7285c5cca58b9f85 9ebcb3c0e2ee91ad -1475-1479 826e8660179fe16a 9d157f6d6e39c7c5 +1475-1479 826e8660179fe16a 54cd756e049c49d7 1480-1484 8ecf1216c69ed810 e1a7dfcb53524ed9 1485-1489 8a3905febae9ece0 97bc211c7cd77ba6 1490-1494 2f1c19a2427058be ccd75a4f323eb2a2 diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index fc95c5d4c..49f819a42 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -164,6 +164,16 @@ frozen, locked, and ordinary installation outcomes separately where supported. (`redirect_workspace_lockfile_elsewhere` hosted, `pypi_uv_workspace_unsupported` vendored) instead of rewriting a Hatch-configured member as a lockless Hatch project (#1138). +- A project with PEP 621 `dynamic = ["dependencies"]` (or + `"optional-dependencies"`) gets its requirement list from the build + backend, so `pyproject.toml` declares nothing the lock root's + `requires-dist` entry could be restored from. Vendored mode refuses the + shape (`pypi_uv_dynamic_dependencies`); hosted mode refuses, before any + write, a package the lock root requires without a static declaration + (`redirect_uv_metadata_unsupported`), since rollback, remove and the + vendored takeover could never undo that entry (#639). A package such a + project only pulls in transitively is still wired through + `override-dependencies`, which unwinds cleanly. - A script lock requires its paired script and a valid PEP 723 metadata block. Missing metadata or an incompatible existing source is reported before either file is rewritten. From 85a9881d2a60bbaf924c8128f4e5262ba6d88cb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 07:01:03 +0000 Subject: [PATCH 4/4] Narrow hosted uv refusal to dynamic projects Review found the first guard both too broad and too narrow. It refused any lock whose root requires a package the pyproject doesn't declare, including stale locks unrelated to #639, which moved 25 golden chunks. And a static dependency group naming the package let a dynamic project's backend-supplied requires-dist entry through, although restore matches that entry only against [project] declarations. Now the refusal fires exactly when project.dynamic lists dependencies or optional-dependencies and the lock root's requires-dist names the package. The uv rewrite golden is back to main's, and a test covers the dependency-group shape. Refs #639 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 26 ++-- .../src/utils/python_lock.rs | 112 ++++++------------ .../equivalence/python_lock_rewrite.golden | 50 ++++---- docs/testing/uv-compatibility.md | 15 +-- 4 files changed, 82 insertions(+), 121 deletions(-) diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index 4b9c9d02e..71dd86e4a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -28215,9 +28215,8 @@ mod uv_old_release_advisories { /// #639: a PEP 621 `dynamic = ["dependencies"]` project gets its dependency /// list from the build backend, so pyproject.toml declares nothing uv's /// root `requires-dist` can be restored from. Hosted mode must refuse to -/// repoint such an entry (vendored refuses the shape with -/// `pypi_uv_dynamic_dependencies`) rather than write wiring that rollback, -/// remove and the vendored takeover can never undo. +/// repoint such an entry rather than write wiring that rollback, remove +/// and the vendored takeover can never undo. #[cfg(test)] mod uv_dynamic_dependencies { use super::*; @@ -28266,24 +28265,29 @@ mod uv_dynamic_dependencies { } /// The backend-supplied `six==1.16.0` is refused for every newline - /// style and for a dynamic optional-dependencies extra: nothing is - /// written, and the warning names the dynamic declaration. + /// style, for a dynamic optional-dependencies extra, and when a static + /// dependency group also names six (restore matches `requires-dist` + /// only against `[project]` declarations): nothing is written, and the + /// warning names the dynamic declaration. #[test] fn backend_declared_dependency_is_refused_before_writing() { + let six_dist = "{ name = \"six\", specifier = \"==1.16.0\" }"; let cases = [ + (project("\"dependencies\""), six_dist), ( - "\"dependencies\"", - "{ name = \"six\", specifier = \"==1.16.0\" }", + project("\"optional-dependencies\""), + "{ name = \"six\", marker = \"extra == 'compat'\", specifier = \"==1.16.0\" }", ), ( - "\"optional-dependencies\"", - "{ name = \"six\", marker = \"extra == 'compat'\", specifier = \"==1.16.0\" }", + project("\"dependencies\"") + "\n[dependency-groups]\ntest = [\"six\"]\n", + six_dist, ), ]; - for (dynamic, requires) in cases { + for (pyproject, requires) in cases { + let dynamic = &pyproject; for newline in ["\n", "\r\n"] { let input = files( - project(dynamic).replace('\n', newline), + pyproject.replace('\n', newline), lock("{ name = \"six\" }", requires, &[SIX]).replace('\n', newline), ); let result = rewrite_registry_redirect(&input, &[six()]); diff --git a/crates/socket-patch-core/src/utils/python_lock.rs b/crates/socket-patch-core/src/utils/python_lock.rs index 98a814ff5..11e2c2a59 100644 --- a/crates/socket-patch-core/src/utils/python_lock.rs +++ b/crates/socket-patch-core/src/utils/python_lock.rs @@ -653,7 +653,7 @@ fn prepare_metadata_completion( let project: DocumentMut = project .parse() .map_err(|error| format!("invalid pyproject.toml: {error}"))?; - refuse_undeclared_root_requirement(document, &project, &name)?; + refuse_dynamic_root_requirement(document, &project, &name)?; let overridden = project .get("tool") .and_then(Item::as_table_like) @@ -719,34 +719,29 @@ fn prepare_metadata_completion( })) } -/// Refuse when the lock's root package requires `name` (in -/// `requires-dist` or `requires-dev`) but `project` declares no `name` -/// requirement statically, as with PEP 621 `dynamic = ["dependencies"]` -/// where the build backend supplies the list (#639). Upstream restore -/// derives the entry's specifier from that declaration, so a repointed -/// entry could never be unwound by rollback, remove or a vendored takeover. -fn refuse_undeclared_root_requirement( +/// Refuse when `project` declares PEP 621 `dynamic` dependencies (or +/// optional-dependencies) and the lock's root package lists `name` in +/// `requires-dist` (#639). That entry came from the build backend, not from +/// pyproject.toml, so upstream restore has no declaration to derive its +/// specifier from, and rollback, remove and a vendored takeover could never +/// undo the repointed entry. `requires-dev` comes from PEP 735 groups, +/// which can't be dynamic, and a transitive package (absent from +/// `requires-dist`) is wired through an override that unwinds cleanly. +fn refuse_dynamic_root_requirement( document: &DocumentMut, project: &DocumentMut, name: &str, ) -> Result<(), String> { - let names = |item: Option<&Item>| -> Vec { - item.and_then(Item::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .map(|spec| canonicalize_pypi_name(crate::vendor::common::pep508_name(spec))) - .collect() - }; - let legacy_dev = project - .get("tool") - .and_then(|tool| tool.get("uv")) - .and_then(|uv| uv.get("dev-dependencies")); - let declared = crate::vendor::common::pyproject_dependency_specs(project) + let dynamic: Vec<&str> = project + .get("project") + .and_then(|p| p.get("dynamic")) + .and_then(Item::as_array) .into_iter() - .any(|(_, spec)| canonicalize_pypi_name(crate::vendor::common::pep508_name(spec)) == name) - || names(legacy_dev).iter().any(|n| n == name); - if declared { + .flatten() + .filter_map(Value::as_str) + .filter(|field| ["dependencies", "optional-dependencies"].contains(field)) + .collect(); + if dynamic.is_empty() { return Ok(()); } let required = document @@ -755,41 +750,25 @@ fn refuse_undeclared_root_requirement( .into_iter() .flatten() .filter(|package| is_root_package(package)) - .filter_map(|package| package.get("metadata").and_then(Item::as_table_like)) - .any(|metadata| { - ["requires-dist", "requires-dev"].into_iter().any(|key| { - requirement_names(metadata.get(key)) - .iter() - .any(|n| n == name) - }) - }); + .filter_map(|package| package.get("metadata")) + .filter_map(|metadata| metadata.get("requires-dist")) + .filter_map(Item::as_array) + .flatten() + .filter_map(Value::as_inline_table) + .filter_map(|requirement| requirement.get("name").and_then(Value::as_str)) + .any(|value| canonicalize_pypi_name(value) == name); if !required { return Ok(()); } - let dynamic: Vec<&str> = project - .get("project") - .and_then(|p| p.get("dynamic")) - .and_then(Item::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .filter(|field| ["dependencies", "optional-dependencies"].contains(field)) - .collect(); - let source = if dynamic.is_empty() { - "pyproject.toml declares no such requirement".to_string() - } else { - format!( - "pyproject.toml declares dynamic = [{}], so the build backend supplies it", - dynamic - .iter() - .map(|field| format!("\"{field}\"")) - .collect::>() - .join(", ") - ) - }; + let fields = dynamic + .iter() + .map(|field| format!("\"{field}\"")) + .collect::>() + .join(", "); Err(format!( - "the lock's root package requires {name}, but {source}; hosted mode leaves it \ - unpinned because rollback could not restore the lock's requirement entry" + "pyproject.toml declares dynamic = [{fields}], so the build backend supplies the \ + project's {name} requirement; hosted mode does not redirect it, since rollback \ + could not restore that requirement in the lock" )) } @@ -806,29 +785,6 @@ fn is_root_package(package: &Table) -> bool { }) } -/// The canonical names of a lock `requires-dist` array, or of every group -/// of a `requires-dev` table. -fn requirement_names(item: Option<&Item>) -> Vec { - let Some(item) = item else { - return Vec::new(); - }; - if let Some(array) = item.as_array() { - array - .iter() - .filter_map(Value::as_inline_table) - .filter_map(|requirement| requirement.get("name").and_then(Value::as_str)) - .map(canonicalize_pypi_name) - .collect() - } else if let Some(table) = item.as_table_like() { - table - .iter() - .flat_map(|(_, item)| requirement_names(Some(item))) - .collect() - } else { - Vec::new() - } -} - fn finish_metadata_completion( document: &mut DocumentMut, completion: MetadataCompletion, diff --git a/crates/socket-patch-core/tests/equivalence/python_lock_rewrite.golden b/crates/socket-patch-core/tests/equivalence/python_lock_rewrite.golden index 693b40d88..553036d9e 100644 --- a/crates/socket-patch-core/tests/equivalence/python_lock_rewrite.golden +++ b/crates/socket-patch-core/tests/equivalence/python_lock_rewrite.golden @@ -11,13 +11,13 @@ 40-44 cf5611d067871c91 a654da53f2bf68f3 45-49 04f0e41a9e302f59 703a8fae640714d8 50-54 03d58da62810b2e4 b8a911ff40818fe5 -55-59 daf9f41b5decbf2c aebe0024c5fcb1a6 +55-59 daf9f41b5decbf2c 4d5c0a6a8091972c 60-64 58413f6f2f266d0d e269d6e79181da6a 65-69 cd4f77526796ec70 ffec6efe319265a7 70-74 3befe3b6747c7c5d e8bf2fc66a89b4c0 75-79 f704740390513318 42df63da4590a254 -80-84 6bb2704eadd90bbb a0c7472747f1a138 -85-89 2b41ca85da12924d 382e97dedcaf5bdf +80-84 6bb2704eadd90bbb d31e655a72986955 +85-89 2b41ca85da12924d 776f725f1d547ae5 90-94 b4f4d21d57b4e3e6 0f3435d61d7a36c1 95-99 1fcae5cb34b3557b 4eb4e16e7ffeb917 100-104 22d4b081083ec3cf 59d0596979c806fd @@ -36,7 +36,7 @@ 165-169 8f16946d73613e52 77dc4a7d5887d3c0 170-174 72c1e1cb26500151 57495ab2d9e9c573 175-179 4333d230209fdfdd ad0aa98f28ad155a -180-184 70af4e6d0a9804eb 0ca949c1554daabd +180-184 70af4e6d0a9804eb 5ff214be13409158 185-189 6073f94564a728c8 42f8708863320edf 190-194 0976869c3d0f4aeb 03f687f9a1ef3754 195-199 6e26da934df4169e 1cfc9815b13e525a @@ -62,15 +62,15 @@ 295-299 99fb36209f586bca e891c6658cee191a 300-304 5240085dd621df82 458bf378d060d912 305-309 616cfc3aae79162c 9773426cddea96c8 -310-314 a386fe7bc32fc73e c53b652fc04125a7 +310-314 a386fe7bc32fc73e 012e854d302744b3 315-319 5d7084c26ae0f3f2 d1ce684178d4a81b 320-324 e0a4ea3772d1038a ade4bdb1bc7842ea 325-329 0cb388111d9a1b86 839131fd61828cf2 330-334 d1c41d95d297d164 35fdd17d28531ad6 335-339 2d6db0a042990332 61f544ab33b2cfd6 340-344 acfe94db4b59c187 7daa8a861d019f20 -345-349 f443e9c02e4c493c e04efc09bfd18439 -350-354 6b8189fa7c7eb359 15a98d47f150e438 +345-349 f443e9c02e4c493c 4770cd61363c20c9 +350-354 6b8189fa7c7eb359 f6a03545fbeec491 355-359 48640c9838b42488 5aaab0260d513d16 360-364 86adc1124beaf310 71f8b11ce3847ea3 365-369 c75c9390e1ec4dc0 9ed2778ab5ff262d @@ -79,21 +79,21 @@ 380-384 bae2ef0777128159 394248e293242674 385-389 a72fe500fd93b54b a48cd849bf320b7b 390-394 1bda7f0386bcb4b2 8a4c74b37267fb35 -395-399 662e2ce03cc70051 f1cb0c0264f4483b +395-399 662e2ce03cc70051 5fed6d12d2490ddc 400-404 27e91e5f8d9d0500 06b82bdf156dad07 405-409 d97cfed3b6019a84 4c43f9bf02b1c878 410-414 9fc1135cea15545b 931a5c267fbea391 415-419 ec0440f6f542cdbe 78e26f5a0c86f7bd 420-424 820d32ca97f38bf1 6e8015b8a4a26459 -425-429 a187a84aad4ddd82 039785b1e46ad44e +425-429 a187a84aad4ddd82 f5d0b8ab277a2913 430-434 63cab5602f883c2b e5605a0a65a3449f 435-439 6b51186155d6b708 9f185e6f0051e7a7 440-444 d3c75dad29457c53 e274e66edc596cb2 445-449 5b312af32839186f 8fbf6337f2932e00 450-454 867d9e5d19789b27 6b144dbc0dd1e95a -455-459 845c435d60d254cd 88afb9584e4bce77 +455-459 845c435d60d254cd 0e0499e922caa21f 460-464 fe6ab0fb975606e6 ec3f3ab76d25c8de -465-469 cf94bc0890135d7d d1e5a41413b44aac +465-469 cf94bc0890135d7d fd4b8e2927bebd03 470-474 5e2dbff33d066336 aca35a27b863300a 475-479 0479e5e66eeb0b35 b85602f8b33a3a40 480-484 728477d2aff05013 0ab363fd26c2e144 @@ -104,7 +104,7 @@ 505-509 45a1e1c9fbf16b34 8d22ee5d9c4a28c1 510-514 818d16cd8b3bec8a ade0d01f88cdff6f 515-519 c7422c418ed92126 4a93055b7f3ee09a -520-524 a3e57516f4ee56fe acac4950f0d5e345 +520-524 a3e57516f4ee56fe 2025a941c447fa1e 525-529 554002fd18293950 43a791951252a9e9 530-534 70f5bc4e1811144d 2d5fece8c7f51bcc 535-539 3bc9d29e9245a772 772970ae662a3597 @@ -116,10 +116,10 @@ 565-569 0b1e0cc286d8e2db 9c09b80a57757558 570-574 0eca795339181b14 adb66e3ab9f7f68c 575-579 5ca964a495b608c9 dccc25c013660626 -580-584 c526bfcdbdb19c13 9eb0cde44dbca274 +580-584 c526bfcdbdb19c13 dd691349d322d087 585-589 c0f55f7b738667c8 f46682e9b36c7702 590-594 9a57465f007322f6 51adc74f13726b01 -595-599 60d49df8b1a1ed60 acb74f75dbf6b585 +595-599 60d49df8b1a1ed60 ea2da15f06594617 600-604 eb8f971fa2c853f1 77603a90b9c742bf 605-609 b9a0d832ab3703a4 f96c87efc5d481bc 610-614 b7cd2826e9ed4bad 1dae82316d44d372 @@ -145,7 +145,7 @@ 710-714 0096b81bcf606d08 09c15a61fa9b7174 715-719 afbe3acb8a936112 df6e9deb669be132 720-724 e212d53b068c9367 aadfdb29a8bcc99e -725-729 b532d08326d3a1c1 82e6dbe6e55e2780 +725-729 b532d08326d3a1c1 ab364c6f8a842555 730-734 fb89f0ab21e41184 8a330cf19706a811 735-739 7f21245b19048f6d 61a95bc86e9de20b 740-744 f5afc189901e25a7 e2e144556544337d @@ -153,7 +153,7 @@ 750-754 a2da5259aa3dc6bb 23d530751e7cdde1 755-759 d5fed3964610e80b 659a2c1a04c6442e 760-764 eb74f4ed1d526a82 959dc5c757d52c8d -765-769 446fbe655c39474a 19fbf839de97d395 +765-769 446fbe655c39474a 65dbfbc0e481e7a1 770-774 3ed2f6336ae1d8b4 577ee0765be7700c 775-779 0c443aac3fea26b0 d014fd768c4bf4c7 780-784 fff5de443b6d9df3 aea7405cafc4c5b7 @@ -175,14 +175,14 @@ 860-864 e0dfd4ee3b9b9115 9bccc43b5c137ed6 865-869 3e913b7411e46c27 4306522989316626 870-874 fdd6c8ad6bcba04e 32ad3ee5e1c12d8e -875-879 daeef0f506171b5e 4a070937908d6fdd +875-879 daeef0f506171b5e f143eb2ccb94a6a1 880-884 a19a9111ca539727 47d9a8a193744011 885-889 4964fd890aa0994b 4af19ffda897291e 890-894 50069923d0e0ff0c ccd6d0e66f60528e 895-899 45a6202dc931bd35 ddc86e63ebde42c6 900-904 3e4b61b6dfc3922b 83e3365600063210 905-909 c91a83be7091fe1c d13f31e52c02bcc7 -910-914 8418b5e59791bc82 8231ec3f06d991b7 +910-914 8418b5e59791bc82 14a923d5c8db097d 915-919 5a056b11f328365b e87365882f0beeba 920-924 34137d01c0b33616 25eb24321c54f780 925-929 2bfe9d227e06cc61 36a2a2804e7199c0 @@ -199,11 +199,11 @@ 980-984 2dfdeb1d730d437f a0d94833e09775ea 985-989 c0ce894b61174b60 3aab2781acd07afb 990-994 a4c96d873de9cde3 eb3b3c6230974260 -995-999 3b004642590efb27 916341a8ce9e15a3 +995-999 3b004642590efb27 de61743f7afd0468 1000-1004 1e11b2228a87e2cd ddbbfeb2ef0cb9a6 1005-1009 863857d4840e1755 355fdf9286f05798 1010-1014 f32eb531a0227019 b9b5a3a04eecda96 -1015-1019 5df98b07a3f6fa22 71987a42c890a925 +1015-1019 5df98b07a3f6fa22 0d2d4cda47b6f7bd 1020-1024 7788148d1188683a 186dad916be4a966 1025-1029 2a42ee2c74e792bf 9cae632550c12f9a 1030-1034 8615096392ecaf53 cebb5c90b23dccac @@ -231,14 +231,14 @@ 1140-1144 643000c4068a73dc 2e95ee9e1f3703cc 1145-1149 a20dbdc962bf496d 0546e3d1c9df5cd0 1150-1154 a8ff0a76fb490212 86381bb67dd20a29 -1155-1159 265ef1e7e4d619b0 672c2224c448ccb8 +1155-1159 265ef1e7e4d619b0 70f4ca757f3efda6 1160-1164 fd031848e1246ce5 be60639cbdf765b0 -1165-1169 46392bbe676ae180 77381760aacc53c9 +1165-1169 46392bbe676ae180 473a5c2505c04570 1170-1174 9ed9aa2666accf82 812866a9c80517d4 1175-1179 42ba5427c32b6a50 f74c503679d95c63 1180-1184 ec53e605cb4007b0 c3e34158cb147df0 1185-1189 a9bca1c3f2f28d14 f9858e5a8f8302ee -1190-1194 99c3c937889a535d 0457b521eda5ef48 +1190-1194 99c3c937889a535d 8acaa3ca71314677 1195-1199 516dc4d7731053d5 b805922b219f6337 1200-1204 615780e5c51bd99d 3f58dc0ca1eb2952 1205-1209 2ba50ff0fb6738c8 8e99678812722afc @@ -255,7 +255,7 @@ 1260-1264 a90cc7dbee7863ee a4f79bdb0ab09720 1265-1269 9601afdc614c3378 07d5b302406eee8d 1270-1274 e4027cab6820ab9c f7909eaf9d4be339 -1275-1279 52f35f77c626c7a9 d57b6e9bb82d2843 +1275-1279 52f35f77c626c7a9 1777115e4bd07213 1280-1284 54db489225a8498f a23b0bca17624b1c 1285-1289 503688b417683963 e173db817b5bd10f 1290-1294 0ec750caf91a885a ba83a3f344246704 @@ -295,7 +295,7 @@ 1460-1464 ce73f815cae74496 4fb2814133a59555 1465-1469 1695babf780735fd cd1492eabdb7ef4a 1470-1474 7285c5cca58b9f85 9ebcb3c0e2ee91ad -1475-1479 826e8660179fe16a 54cd756e049c49d7 +1475-1479 826e8660179fe16a 9d157f6d6e39c7c5 1480-1484 8ecf1216c69ed810 e1a7dfcb53524ed9 1485-1489 8a3905febae9ece0 97bc211c7cd77ba6 1490-1494 2f1c19a2427058be ccd75a4f323eb2a2 diff --git a/docs/testing/uv-compatibility.md b/docs/testing/uv-compatibility.md index 49f819a42..3ca0fd11e 100644 --- a/docs/testing/uv-compatibility.md +++ b/docs/testing/uv-compatibility.md @@ -167,13 +167,14 @@ frozen, locked, and ordinary installation outcomes separately where supported. - A project with PEP 621 `dynamic = ["dependencies"]` (or `"optional-dependencies"`) gets its requirement list from the build backend, so `pyproject.toml` declares nothing the lock root's - `requires-dist` entry could be restored from. Vendored mode refuses the - shape (`pypi_uv_dynamic_dependencies`); hosted mode refuses, before any - write, a package the lock root requires without a static declaration - (`redirect_uv_metadata_unsupported`), since rollback, remove and the - vendored takeover could never undo that entry (#639). A package such a - project only pulls in transitively is still wired through - `override-dependencies`, which unwinds cleanly. + `requires-dist` entry could be restored from. Vendored mode refuses + `dynamic = ["dependencies"]` outright (`pypi_uv_dynamic_dependencies`). + Hosted mode refuses, before any write, a package the lock root's + `requires-dist` lists in such a project + (`redirect_uv_metadata_unsupported`), since rollback and remove could + never restore that entry (#639). A package the project only pulls in + transitively is still wired through `override-dependencies`, which + rollback and remove undo. - A script lock requires its paired script and a valid PEP 723 metadata block. Missing metadata or an incompatible existing source is reported before either file is rewritten.