Skip to content

Commit 73be80e

Browse files
Fix hosted Hatch ignoring uv sources (#673) (#1706)
* Start fix for #673 Assisted-by: Claude Code:claude-opus-5-5 * 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 * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5302faf commit 73be80e

3 files changed

Lines changed: 211 additions & 4 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27297,6 +27297,32 @@ mod hatch_tests {
2729727297
assert!(result.confirmed_hatch_uuids.is_empty());
2729827298
}
2729927299

27300+
/// #673: Hatch's uv installer applies `[tool.uv.sources]` over the
27301+
/// hosted reference, so the dep stays unconfirmed (nothing written)
27302+
/// instead of reporting a redirect that installs the unpatched copy.
27303+
#[test]
27304+
fn hatch_uv_source_leaves_the_dep_unconfirmed() {
27305+
let files: BTreeMap<String, String> = [(
27306+
"pyproject.toml".to_string(),
27307+
"[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(),
27308+
)]
27309+
.into_iter()
27310+
.collect();
27311+
let result = rewrite_registry_redirect(&files, &[patch()]);
27312+
assert!(result.hatch_uuids.contains("test-uuid"));
27313+
assert!(result.confirmed_hatch_uuids.is_empty());
27314+
assert!(!result.files.contains_key("pyproject.toml"));
27315+
assert!(
27316+
result
27317+
.warnings
27318+
.iter()
27319+
.any(|w| w.code == "redirect_hatch_unsupported"
27320+
&& w.detail.contains("[tool.uv.sources]")),
27321+
"{:?}",
27322+
result.warnings
27323+
);
27324+
}
27325+
2730027326
#[test]
2730127327
fn hatch_confirmation_ignores_inactive_sources_and_comments() {
2730227328
let dep = patch();

‎crates/socket-patch-core/src/utils/hatch.rs‎

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::{BTreeMap, HashMap};
1+
use std::collections::{BTreeMap, HashMap, HashSet};
22

33
use toml_edit::{DocumentMut, Item, Value};
44

@@ -295,6 +295,9 @@ struct Round<'a> {
295295
targets: Vec<Target<'a>>,
296296
index: HashMap<String, usize>,
297297
apply: bool,
298+
/// Canonical names whose `[tool.uv.sources]` entry Hatch's uv
299+
/// installer applies over a project-table direct reference.
300+
uv_sources: HashSet<String>,
298301
}
299302

300303
impl Round<'_> {
@@ -380,7 +383,9 @@ fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(),
380383
.as_table_like_mut()
381384
.and_then(|table| table.get_mut("dependencies"))
382385
{
383-
rewrite_array(dependencies, round, file, true)?;
386+
for index in rewrite_array(dependencies, round, file, true)? {
387+
refuse_uv_source(round, index);
388+
}
384389
}
385390
if let Some(groups) = project
386391
.as_table_like_mut()
@@ -391,7 +396,9 @@ fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(),
391396
.ok_or("optional dependencies must be a table")?
392397
.iter_mut()
393398
{
394-
rewrite_array(dependencies, round, file, true)?;
399+
for index in rewrite_array(dependencies, round, file, true)? {
400+
refuse_uv_source(round, index);
401+
}
395402
}
396403
}
397404
}
@@ -412,6 +419,66 @@ fn rewrite_project(document: &mut DocumentMut, round: &mut Round) -> Result<(),
412419
Ok(())
413420
}
414421

422+
/// #673: Hatch's uv installer runs `uv pip install -e .`, which resolves a
423+
/// project requirement through `[tool.uv.sources]` instead of its direct
424+
/// reference: a url or path source installs the unpatched copy, a git
425+
/// source makes every install fail on conflicting URLs. Environment
426+
/// dependencies reach uv as plain strings, so only the project tables
427+
/// (`dependencies`, `optional-dependencies`) are refused.
428+
fn refuse_uv_source(round: &mut Round, index: usize) {
429+
let target = &mut round.targets[index];
430+
if round.uv_sources.contains(&target.name) {
431+
target.error.get_or_insert_with(|| {
432+
format!(
433+
"{}: Hatch's uv installer resolves it through its [tool.uv.sources] entry instead of the hosted reference; use agent mode",
434+
target.name
435+
)
436+
});
437+
}
438+
}
439+
440+
/// The canonical names of `[tool.uv.sources]` entries other than index
441+
/// sources (which a direct URL overrides) when a Hatch environment uses
442+
/// the uv installer, else none: pip ignores the table. `uv_env` is a
443+
/// non-empty `HATCH_ENV_TYPE_VIRTUAL_UV_PATH`, which switches every
444+
/// environment to uv as `uv-path` does.
445+
fn uv_source_names(
446+
documents: &BTreeMap<&'static str, DocumentMut>,
447+
uv_env: bool,
448+
) -> HashSet<String> {
449+
if !uv_env && require_pip_installer_documents(documents.values()).is_ok() {
450+
return HashSet::new();
451+
}
452+
// An empty array is never judged index-only.
453+
let index_only = |source: &Item| match source {
454+
Item::Value(Value::Array(sources)) => {
455+
!sources.is_empty()
456+
&& sources.iter().all(|source| {
457+
source
458+
.as_inline_table()
459+
.is_some_and(|table| table.contains_key("index"))
460+
})
461+
}
462+
Item::ArrayOfTables(sources) => {
463+
!sources.is_empty() && sources.iter().all(|table| table.contains_key("index"))
464+
}
465+
source => source
466+
.as_table_like()
467+
.is_some_and(|table| table.contains_key("index")),
468+
};
469+
documents
470+
.get(HATCH_FILES[0])
471+
.and_then(|document| document.get("tool"))
472+
.and_then(|tool| tool.get("uv"))
473+
.and_then(|uv| uv.get("sources"))
474+
.and_then(Item::as_table_like)
475+
.into_iter()
476+
.flat_map(|sources| sources.iter())
477+
.filter(|(_, source)| !index_only(source))
478+
.map(|(name, _)| canonicalize_pypi_name(name))
479+
.collect()
480+
}
481+
415482
fn rewrite_environments(hatch: &mut Item, round: &mut Round, file: &str) -> Result<(), String> {
416483
if hatch.get("sources").is_some() || hatch.get("env").is_some() {
417484
return Err("Hatch sources and environment plugins require agent mode".into());
@@ -517,6 +584,10 @@ fn run_round(documents: &mut BTreeMap<&'static str, DocumentMut>, round: &mut Ro
517584
}
518585
}
519586
}
587+
round.uv_sources = uv_source_names(
588+
documents,
589+
std::env::var("HATCH_ENV_TYPE_VIRTUAL_UV_PATH").is_ok_and(|path| !path.is_empty()),
590+
);
520591
round.apply = false;
521592
if let Err(error) = walk(documents, round) {
522593
round.fail_rest(&error);
@@ -679,6 +750,7 @@ pub fn rewrite_all(files: &BTreeMap<String, String>, deps: &[HatchDep]) -> Hatch
679750
.map(|(index, &position)| (names[position].clone(), index))
680751
.collect(),
681752
apply: false,
753+
uv_sources: HashSet::new(),
682754
};
683755
run_round(&mut documents, &mut round);
684756
for (position, target) in positions.into_iter().zip(round.targets) {
@@ -850,6 +922,7 @@ pub fn plan(
850922
}],
851923
index: HashMap::from([(name, 0)]),
852924
apply: false,
925+
uv_sources: HashSet::new(),
853926
};
854927
run_round(&mut documents, &mut round);
855928
let target = round.targets.pop().ok_or("missing Hatch target")?;
@@ -1111,6 +1184,112 @@ mod tests {
11111184
}
11121185
}
11131186

1187+
/// #673: Hatch's uv installer runs `uv pip install -e .`, which applies
1188+
/// the project's `[tool.uv.sources]` over a direct reference in the
1189+
/// project tables, so a url, path or git source would keep the
1190+
/// unpatched copy (or break every install). Environment dependencies
1191+
/// reach uv as plain strings, index sources lose to the direct URL, and
1192+
/// pip ignores the table: those still rewrite.
1193+
#[test]
1194+
fn uv_sources_refuse_project_rewrites_under_the_uv_installer() {
1195+
let url = "https://patch.test/six-1.16.0-py2.py3-none-any.whl";
1196+
let sources = [
1197+
"six = { url = \"https://files.test/six-1.16.0-py2.py3-none-any.whl\" }",
1198+
"six = { path = \"/abs/six-1.16.0-py2.py3-none-any.whl\" }",
1199+
"six = { git = \"https://github.com/benjaminp/six\", tag = \"1.16.0\" }",
1200+
"Six = { url = \"https://files.test/six.whl\" }",
1201+
"six = [{ index = \"internal\", marker = \"sys_platform == 'win32'\" }, { path = \"/abs/six.whl\" }]",
1202+
];
1203+
let declarations = [
1204+
"[project]\ndependencies = [\"six==1.16.0\"]\n",
1205+
"[project]\noptional-dependencies.x = [\"six==1.16.0\"]\n",
1206+
];
1207+
for source in sources {
1208+
for declaration in declarations {
1209+
for setting in ["installer = \"uv\"", "uv-path = \"uv\""] {
1210+
let inputs = files(&format!(
1211+
"{declaration}[tool.hatch.envs.default]\n{setting}\n[tool.uv.sources]\n{source}\n"
1212+
));
1213+
let error = rewrite(&inputs, "six", "1.16.0", url).unwrap_err();
1214+
assert!(
1215+
error.contains("[tool.uv.sources]"),
1216+
"{source} {declaration}: {error}"
1217+
);
1218+
let external = both(
1219+
&format!("{declaration}[tool.uv.sources]\n{source}\n"),
1220+
Some(&format!("[envs.default]\n{setting}\n")),
1221+
);
1222+
assert!(rewrite(&external, "six", "1.16.0", url)
1223+
.unwrap_err()
1224+
.contains("[tool.uv.sources]"));
1225+
}
1226+
// pip ignores [tool.uv.sources].
1227+
let pip = files(&format!("{declaration}[tool.uv.sources]\n{source}\n"));
1228+
assert!(rewrite(&pip, "six", "1.16.0", url).is_ok(), "{source}");
1229+
}
1230+
}
1231+
let uv = "[tool.hatch.envs.default]\ninstaller = \"uv\"\n";
1232+
// Index sources lose to the direct URL.
1233+
let index = files(&format!(
1234+
"[project]\ndependencies = [\"six==1.16.0\"]\n{uv}[tool.uv.sources]\nsix = {{ index = \"internal\" }}\n"
1235+
));
1236+
assert!(rewrite(&index, "six", "1.16.0", url).is_ok());
1237+
// A source for another package leaves this one alone.
1238+
let other = files(&format!(
1239+
"[project]\ndependencies = [\"six==1.16.0\"]\n{uv}[tool.uv.sources]\nidna = {{ path = \"/abs/idna.whl\" }}\n"
1240+
));
1241+
assert!(rewrite(&other, "six", "1.16.0", url).is_ok());
1242+
// HATCH_ENV_TYPE_VIRTUAL_UV_PATH switches Hatch to uv with no
1243+
// file setting; an empty source array is not index-only.
1244+
let sourced = |source: &str| -> BTreeMap<&'static str, DocumentMut> {
1245+
[(
1246+
HATCH_FILES[0],
1247+
format!("[project]\ndependencies = [\"six==1.16.0\"]\n[tool.uv.sources]\nsix = {source}\n")
1248+
.parse()
1249+
.unwrap(),
1250+
)]
1251+
.into_iter()
1252+
.collect()
1253+
};
1254+
let path = sourced("{ path = \"/abs/six.whl\" }");
1255+
assert!(uv_source_names(&path, false).is_empty());
1256+
assert!(uv_source_names(&path, true).contains("six"));
1257+
assert!(uv_source_names(&sourced("[]"), true).contains("six"));
1258+
assert!(uv_source_names(&sourced("{ index = \"internal\" }"), true).is_empty());
1259+
// Environment dependencies reach uv as plain strings.
1260+
let env = files(&format!(
1261+
"[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"
1262+
));
1263+
assert!(rewrite(&env, "six", "1.16.0", url).is_ok());
1264+
// One refused package writes nothing for itself, and the other
1265+
// package of the batch still rewrites.
1266+
let mixed = files(&format!(
1267+
"[project]\ndependencies = [\"six==1.16.0\", \"idna==3.7\"]\n{uv}[tool.uv.sources]\nsix = {{ path = \"/abs/six.whl\" }}\n"
1268+
));
1269+
let deps = [
1270+
HatchDep {
1271+
name: "six",
1272+
version: "1.16.0",
1273+
url,
1274+
},
1275+
HatchDep {
1276+
name: "idna",
1277+
version: "3.7",
1278+
url: "https://patch.test/idna-3.7-py3-none-any.whl",
1279+
},
1280+
];
1281+
assert_batch_matches_sequential(&mixed, &deps);
1282+
let batch = rewrite_all(&mixed, &deps);
1283+
assert!(batch.steps[0]
1284+
.as_ref()
1285+
.unwrap_err()
1286+
.contains("[tool.uv.sources]"));
1287+
assert!(batch.steps[1].is_ok());
1288+
let pyproject = &batch.files["pyproject.toml"];
1289+
assert!(pyproject.contains("\"six==1.16.0\""));
1290+
assert!(pyproject.contains("idna @ https://patch.test/"));
1291+
}
1292+
11141293
#[test]
11151294
fn require_pip_installer_refuses_uv_settings_in_either_file() {
11161295
for setting in ["installer='uv'", "uv-path='uv'"] {

‎docs/testing/hatch.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ A `pylock.toml` in a Hatch project without locked environments (`uv export`,
3535

3636
A range, transitive-only declaration, dynamic dependency metadata, custom
3737
environment plugin, source table or conditional override is refused before
38-
writing. Use agent mode (`scan --mode agent` + `socket-patch apply` in CI) for these shapes. Hosted PEP 735 groups are
38+
writing. So is a project-table pin of a package with a url, path, git or
39+
workspace entry in `[tool.uv.sources]` when an environment uses the uv
40+
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
3941
supported; vendored groups are refused because Hatch does not expand
4042
`{root:uri}` within dependency groups. Unknown direct sources require an
4143
explicit revert before patching. A hosted scan re-pins socket-patch's own

0 commit comments

Comments
 (0)