Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> = [(
"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();
Expand Down
185 changes: 182 additions & 3 deletions crates/socket-patch-core/src/utils/hatch.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::collections::{BTreeMap, HashMap, HashSet};

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

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

impl Round<'_> {
Expand Down Expand Up @@ -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()
Expand All @@ -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);
}
}
}
}
Expand All @@ -412,6 +419,66 @@ 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. `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<String> {
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.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")),
};
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());
Expand Down Expand Up @@ -517,6 +584,10 @@ fn run_round(documents: &mut BTreeMap<&'static str, DocumentMut>, round: &mut Ro
}
}
}
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);
Expand Down Expand Up @@ -679,6 +750,7 @@ pub fn rewrite_all(files: &BTreeMap<String, String>, 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) {
Expand Down Expand Up @@ -850,6 +922,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")?;
Expand Down Expand Up @@ -1111,6 +1184,112 @@ 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());
// 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"
));
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'"] {
Expand Down
4 changes: 3 additions & 1 deletion docs/testing/hatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading