From 632ba9de3404bc2df3067711830a15180640005a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 02:28:45 +0000 Subject: [PATCH 1/3] Start fix for #673 Assisted-by: Claude Code:claude-opus-5-5 From 4d216e3134d5a07bb67a9dc31cac7f7afcf7576f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 02:33:19 +0000 Subject: [PATCH 2/3] Refuse hosted Hatch pins shadowed by uv sources When a Hatch environment uses the uv installer, uv resolves a project dependency through the project's [tool.uv.sources] entry instead of the direct reference hosted mode writes. A url or path source then installs the unpatched wheel while the scan reports "redirected", and a git source breaks every install with conflicting URLs. Hosted Hatch now refuses such a pin before writing, as it already does for Hatch's own source tables, and reports redirect_hatch_unsupported. Environment dependencies, index-only sources and the pip installer are unaffected and still rewrite. Fixes #673 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/patch/redirect/mod.rs | 26 +++ crates/socket-patch-core/src/utils/hatch.rs | 154 +++++++++++++++++- docs/testing/hatch.md | 4 +- 3 files changed, 180 insertions(+), 4 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..1fe43c82a 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -27165,6 +27165,32 @@ mod hatch_tests { assert!(result.confirmed_hatch_uuids.is_empty()); } + /// #673: Hatch's uv installer applies `[tool.uv.sources]` over the + /// hosted reference, so the dep stays unconfirmed (nothing written) + /// instead of reporting a redirect that installs the unpatched copy. + #[test] + fn hatch_uv_source_leaves_the_dep_unconfirmed() { + let files: BTreeMap = [( + "pyproject.toml".to_string(), + "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"urllib3==1.26.18\"]\n\n[tool.hatch.envs.default]\ninstaller = \"uv\"\n\n[tool.uv.sources]\nurllib3 = { url = \"https://files.test/urllib3-1.26.18-py2.py3-none-any.whl\" }\n".to_string(), + )] + .into_iter() + .collect(); + let result = rewrite_registry_redirect(&files, &[patch()]); + assert!(result.hatch_uuids.contains("test-uuid")); + assert!(result.confirmed_hatch_uuids.is_empty()); + assert!(!result.files.contains_key("pyproject.toml")); + assert!( + result + .warnings + .iter() + .any(|w| w.code == "redirect_hatch_unsupported" + && w.detail.contains("[tool.uv.sources]")), + "{:?}", + result.warnings + ); + } + #[test] fn hatch_confirmation_ignores_inactive_sources_and_comments() { let dep = patch(); diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index 914e7c535..16d034961 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use toml_edit::{DocumentMut, Item, Value}; @@ -295,6 +295,9 @@ struct Round<'a> { targets: Vec>, index: HashMap, apply: bool, + /// Canonical names whose `[tool.uv.sources]` entry Hatch's uv + /// installer applies over a project-table direct reference. + uv_sources: HashSet, } impl Round<'_> { @@ -380,7 +383,9 @@ fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(), .as_table_like_mut() .and_then(|table| table.get_mut("dependencies")) { - rewrite_array(dependencies, round, file, true)?; + for index in rewrite_array(dependencies, round, file, true)? { + refuse_uv_source(round, index); + } } if let Some(groups) = project .as_table_like_mut() @@ -391,7 +396,9 @@ fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(), .ok_or("optional dependencies must be a table")? .iter_mut() { - rewrite_array(dependencies, round, file, true)?; + for index in rewrite_array(dependencies, round, file, true)? { + refuse_uv_source(round, index); + } } } } @@ -412,6 +419,55 @@ fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(), Ok(()) } +/// #673: Hatch's uv installer runs `uv pip install -e .`, which resolves a +/// project requirement through `[tool.uv.sources]` instead of its direct +/// reference: a url or path source installs the unpatched copy, a git +/// source makes every install fail on conflicting URLs. Environment +/// dependencies reach uv as plain strings, so only the project tables +/// (`dependencies`, `optional-dependencies`) are refused. +fn refuse_uv_source(round: &mut Round, index: usize) { + let target = &mut round.targets[index]; + if round.uv_sources.contains(&target.name) { + target.error.get_or_insert_with(|| { + format!( + "{}: Hatch's uv installer resolves it through its [tool.uv.sources] entry instead of the hosted reference; use agent mode", + target.name + ) + }); + } +} + +/// The canonical names of `[tool.uv.sources]` entries other than index +/// sources (which a direct URL overrides) when a Hatch environment uses +/// the uv installer, else none: pip ignores the table. +fn uv_source_names(documents: &BTreeMap<&'static str, DocumentMut>) -> HashSet { + if require_pip_installer_documents(documents.values()).is_ok() { + return HashSet::new(); + } + let index_only = |source: &Item| match source { + Item::Value(Value::Array(sources)) => sources.iter().all(|source| { + source + .as_inline_table() + .is_some_and(|table| table.contains_key("index")) + }), + Item::ArrayOfTables(sources) => sources.iter().all(|table| table.contains_key("index")), + source => source + .as_table_like() + .is_some_and(|table| table.contains_key("index")), + }; + documents + .get(HATCH_FILES[0]) + .and_then(|document| document.get("tool")) + .and_then(|tool| tool.get("uv")) + .and_then(|uv| uv.get("sources")) + .and_then(Item::as_table_like) + .into_iter() + .flat_map(|sources| sources.iter()) + .filter(|(_, source)| !index_only(source)) + .map(|(name, _)| canonicalize_pypi_name(name)) + .collect() +} + fn rewrite_environments(hatch: &mut Item, round: &mut Round, file: &str) -> Result<(), String> { if hatch.get("sources").is_some() || hatch.get("env").is_some() { return Err("Hatch sources and environment plugins require agent mode".into()); @@ -517,6 +573,7 @@ fn run_round(documents: &mut BTreeMap<&'static str, DocumentMut>, round: &mut Ro } } } + round.uv_sources = uv_source_names(documents); round.apply = false; if let Err(error) = walk(documents, round) { round.fail_rest(&error); @@ -679,6 +736,7 @@ pub fn rewrite_all(files: &BTreeMap, deps: &[HatchDep]) -> Hatch .map(|(index, &position)| (names[position].clone(), index)) .collect(), apply: false, + uv_sources: HashSet::new(), }; run_round(&mut documents, &mut round); for (position, target) in positions.into_iter().zip(round.targets) { @@ -850,6 +908,7 @@ pub fn plan( }], index: HashMap::from([(name, 0)]), apply: false, + uv_sources: HashSet::new(), }; run_round(&mut documents, &mut round); let target = round.targets.pop().ok_or("missing Hatch target")?; @@ -1111,6 +1170,95 @@ mod tests { } } + /// #673: Hatch's uv installer runs `uv pip install -e .`, which applies + /// the project's `[tool.uv.sources]` over a direct reference in the + /// project tables, so a url, path or git source would keep the + /// unpatched copy (or break every install). Environment dependencies + /// reach uv as plain strings, index sources lose to the direct URL, and + /// pip ignores the table: those still rewrite. + #[test] + fn uv_sources_refuse_project_rewrites_under_the_uv_installer() { + let url = "https://patch.test/six-1.16.0-py2.py3-none-any.whl"; + let sources = [ + "six = { url = \"https://files.test/six-1.16.0-py2.py3-none-any.whl\" }", + "six = { path = \"/abs/six-1.16.0-py2.py3-none-any.whl\" }", + "six = { git = \"https://github.com/benjaminp/six\", tag = \"1.16.0\" }", + "Six = { url = \"https://files.test/six.whl\" }", + "six = [{ index = \"internal\", marker = \"sys_platform == 'win32'\" }, { path = \"/abs/six.whl\" }]", + ]; + let declarations = [ + "[project]\ndependencies = [\"six==1.16.0\"]\n", + "[project]\noptional-dependencies.x = [\"six==1.16.0\"]\n", + ]; + for source in sources { + for declaration in declarations { + for setting in ["installer = \"uv\"", "uv-path = \"uv\""] { + let inputs = files(&format!( + "{declaration}[tool.hatch.envs.default]\n{setting}\n[tool.uv.sources]\n{source}\n" + )); + let error = rewrite(&inputs, "six", "1.16.0", url).unwrap_err(); + assert!( + error.contains("[tool.uv.sources]"), + "{source} {declaration}: {error}" + ); + let external = both( + &format!("{declaration}[tool.uv.sources]\n{source}\n"), + Some(&format!("[envs.default]\n{setting}\n")), + ); + assert!(rewrite(&external, "six", "1.16.0", url) + .unwrap_err() + .contains("[tool.uv.sources]")); + } + // pip ignores [tool.uv.sources]. + let pip = files(&format!("{declaration}[tool.uv.sources]\n{source}\n")); + assert!(rewrite(&pip, "six", "1.16.0", url).is_ok(), "{source}"); + } + } + let uv = "[tool.hatch.envs.default]\ninstaller = \"uv\"\n"; + // Index sources lose to the direct URL. + let index = files(&format!( + "[project]\ndependencies = [\"six==1.16.0\"]\n{uv}[tool.uv.sources]\nsix = {{ index = \"internal\" }}\n" + )); + assert!(rewrite(&index, "six", "1.16.0", url).is_ok()); + // A source for another package leaves this one alone. + let other = files(&format!( + "[project]\ndependencies = [\"six==1.16.0\"]\n{uv}[tool.uv.sources]\nidna = {{ path = \"/abs/idna.whl\" }}\n" + )); + assert!(rewrite(&other, "six", "1.16.0", url).is_ok()); + // Environment dependencies reach uv as plain strings. + let env = files(&format!( + "[project]\nname = \"app\"\n[tool.hatch.envs.default]\ninstaller = \"uv\"\ndependencies = [\"six==1.16.0\"]\n[tool.uv.sources]\nsix = {{ path = \"/abs/six.whl\" }}\n" + )); + assert!(rewrite(&env, "six", "1.16.0", url).is_ok()); + // One refused package writes nothing for itself, and the other + // package of the batch still rewrites. + let mixed = files(&format!( + "[project]\ndependencies = [\"six==1.16.0\", \"idna==3.7\"]\n{uv}[tool.uv.sources]\nsix = {{ path = \"/abs/six.whl\" }}\n" + )); + let deps = [ + HatchDep { + name: "six", + version: "1.16.0", + url, + }, + HatchDep { + name: "idna", + version: "3.7", + url: "https://patch.test/idna-3.7-py3-none-any.whl", + }, + ]; + assert_batch_matches_sequential(&mixed, &deps); + let batch = rewrite_all(&mixed, &deps); + assert!(batch.steps[0] + .as_ref() + .unwrap_err() + .contains("[tool.uv.sources]")); + assert!(batch.steps[1].is_ok()); + let pyproject = &batch.files["pyproject.toml"]; + assert!(pyproject.contains("\"six==1.16.0\"")); + assert!(pyproject.contains("idna @ https://patch.test/")); + } + #[test] fn require_pip_installer_refuses_uv_settings_in_either_file() { for setting in ["installer='uv'", "uv-path='uv'"] { diff --git a/docs/testing/hatch.md b/docs/testing/hatch.md index 248089223..46e7cc859 100644 --- a/docs/testing/hatch.md +++ b/docs/testing/hatch.md @@ -35,7 +35,9 @@ A `pylock.toml` in a Hatch project without locked environments (`uv export`, A range, transitive-only declaration, dynamic dependency metadata, custom environment plugin, source table or conditional override is refused before -writing. Use agent mode (`scan --mode agent` + `socket-patch apply` in CI) for these shapes. Hosted PEP 735 groups are +writing. So is a project-table pin of a package with a url, path, git or +workspace entry in `[tool.uv.sources]` when an environment uses the uv +installer: uv applies that source over the hosted reference (#673). Use agent mode (`scan --mode agent` + `socket-patch apply` in CI) for these shapes. Hosted PEP 735 groups are supported; vendored groups are refused because Hatch does not expand `{root:uri}` within dependency groups. Unknown direct sources require an explicit revert before patching. A hosted scan re-pins socket-patch's own From 84d95a41da3ec23f57408b2b06cc2402bc280033 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 03:14:17 +0000 Subject: [PATCH 3/3] Also refuse uv sources under the uv env switch HATCH_ENV_TYPE_VIRTUAL_UV_PATH switches Hatch to the uv installer with no file setting, so a url or path source still shadowed the hosted pin there. An empty source array is no longer treated as index-only. Refs #673 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/utils/hatch.rs | 51 +++++++++++++++++---- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/crates/socket-patch-core/src/utils/hatch.rs b/crates/socket-patch-core/src/utils/hatch.rs index 16d034961..9e11471dc 100644 --- a/crates/socket-patch-core/src/utils/hatch.rs +++ b/crates/socket-patch-core/src/utils/hatch.rs @@ -439,18 +439,29 @@ fn refuse_uv_source(round: &mut Round, index: usize) { /// The canonical names of `[tool.uv.sources]` entries other than index /// sources (which a direct URL overrides) when a Hatch environment uses -/// the uv installer, else none: pip ignores the table. -fn uv_source_names(documents: &BTreeMap<&'static str, DocumentMut>) -> HashSet { - if require_pip_installer_documents(documents.values()).is_ok() { +/// the uv installer, else none: pip ignores the table. `uv_env` is a +/// non-empty `HATCH_ENV_TYPE_VIRTUAL_UV_PATH`, which switches every +/// environment to uv as `uv-path` does. +fn uv_source_names( + documents: &BTreeMap<&'static str, DocumentMut>, + uv_env: bool, +) -> HashSet { + if !uv_env && require_pip_installer_documents(documents.values()).is_ok() { return HashSet::new(); } + // An empty array is never judged index-only. let index_only = |source: &Item| match source { - Item::Value(Value::Array(sources)) => sources.iter().all(|source| { - source - .as_inline_table() - .is_some_and(|table| table.contains_key("index")) - }), - Item::ArrayOfTables(sources) => sources.iter().all(|table| table.contains_key("index")), + Item::Value(Value::Array(sources)) => { + !sources.is_empty() + && sources.iter().all(|source| { + source + .as_inline_table() + .is_some_and(|table| table.contains_key("index")) + }) + } + Item::ArrayOfTables(sources) => { + !sources.is_empty() && sources.iter().all(|table| table.contains_key("index")) + } source => source .as_table_like() .is_some_and(|table| table.contains_key("index")), @@ -573,7 +584,10 @@ fn run_round(documents: &mut BTreeMap<&'static str, DocumentMut>, round: &mut Ro } } } - round.uv_sources = uv_source_names(documents); + round.uv_sources = uv_source_names( + documents, + std::env::var("HATCH_ENV_TYPE_VIRTUAL_UV_PATH").is_ok_and(|path| !path.is_empty()), + ); round.apply = false; if let Err(error) = walk(documents, round) { round.fail_rest(&error); @@ -1225,6 +1239,23 @@ mod tests { "[project]\ndependencies = [\"six==1.16.0\"]\n{uv}[tool.uv.sources]\nidna = {{ path = \"/abs/idna.whl\" }}\n" )); assert!(rewrite(&other, "six", "1.16.0", url).is_ok()); + // HATCH_ENV_TYPE_VIRTUAL_UV_PATH switches Hatch to uv with no + // file setting; an empty source array is not index-only. + let sourced = |source: &str| -> BTreeMap<&'static str, DocumentMut> { + [( + HATCH_FILES[0], + format!("[project]\ndependencies = [\"six==1.16.0\"]\n[tool.uv.sources]\nsix = {source}\n") + .parse() + .unwrap(), + )] + .into_iter() + .collect() + }; + let path = sourced("{ path = \"/abs/six.whl\" }"); + assert!(uv_source_names(&path, false).is_empty()); + assert!(uv_source_names(&path, true).contains("six")); + assert!(uv_source_names(&sourced("[]"), true).contains("six")); + assert!(uv_source_names(&sourced("{ index = \"internal\" }"), true).is_empty()); // Environment dependencies reach uv as plain strings. let env = files(&format!( "[project]\nname = \"app\"\n[tool.hatch.envs.default]\ninstaller = \"uv\"\ndependencies = [\"six==1.16.0\"]\n[tool.uv.sources]\nsix = {{ path = \"/abs/six.whl\" }}\n"