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
182 changes: 182 additions & 0 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,188 @@ async fn hatch_vendored_to_hosted() {
assert_vendored_to_hosted(&root, files).await;
}

/// Stages one flavor's project files; returns its wiring files.
type StageFn = fn(&Path) -> &'static [&'static str];

/// A uv PEP 723 script with its `.py.lock`; returns its wiring files.
fn stage_script_lock(root: &Path) -> &'static [&'static str] {
std::fs::write(
root.join("job.py"),
"# /// script\n# requires-python = \">=3.9\"\n# dependencies = [\"six==1.16.0\"]\n# ///\nimport six\n",
)
.unwrap();
std::fs::write(
root.join("job.py.lock"),
format!(
"version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n\n[manifest]\nrequirements = [{{name = \"six\", specifier = \"==1.16.0\"}}]\n\n[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = {{registry = \"https://pypi.org/simple\"}}\nwheels = [{{url = \"https://files.pythonhosted.org/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:{WHEEL_SHA}\"}}]\n"
),
)
.unwrap();
&["job.py", "job.py.lock"]
}

/// #742 / #650: a vendored uv project, uv script lock and Hatch project pick
/// up a superseding patch. The manifest moves `six` from patch A to patch B
/// (different patched bytes); the next `vendor` must wire B's wheel, remove
/// A's uuid dir (`vendor_stale_artifact_removed`) and exit 0. Before the fix
/// it failed `pypi_uv_source_already_exists`,
/// `pypi_lock_source_already_exists` or `pypi_hatch_unsupported` (exit 1)
/// and the project kept installing patch A. `vendor --revert` afterwards
/// restores the user's original files byte for byte.
#[tokio::test]
async fn pyproject_flavors_vendored_revendor_superseding_patch() {
const UUID_B: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6d";
const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n";
let stages: [(&str, StageFn); 3] = [
("uv", stage_uv),
("script lock", stage_script_lock),
("hatch", stage_hatch),
];
for (flavor, stage) in stages {
let (_tmp, root) = project();
let files = stage(&root);
let originals: Vec<String> = files
.iter()
.map(|f| std::fs::read_to_string(root.join(f)).unwrap())
.collect();
vendor_project(&root, files);

stage_manifest_with(&root, UUID_B, PATCHED_B);
let (code, env) = run_cli(&root, &["vendor"], &[]);
assert_eq!(
code, 0,
"{flavor}: re-vendor to the superseding patch: {env:#}"
);
let rendered = env.to_string();
assert!(!rendered.contains("already_exists"), "{flavor}: {env:#}");
assert!(
rendered.contains("vendor_stale_artifact_removed"),
"{flavor}: patch A's artifact is reclaimed: {env:#}"
);
let wired_b: Vec<String> = files
.iter()
.map(|f| std::fs::read_to_string(root.join(f)).unwrap())
.collect();
for (f, text) in files.iter().zip(&wired_b) {
assert!(!text.contains(UUID), "{flavor}: {f} kept uuid A:\n{text}");
}
assert!(
wired_b
.iter()
.any(|t| t.contains(&format!(".socket/vendor/pypi/{UUID_B}/"))),
"{flavor}: wired to patch B: {wired_b:#?}"
);
assert!(
!root.join(format!(".socket/vendor/pypi/{UUID}")).exists(),
"{flavor}"
);
assert!(
root.join(format!(".socket/vendor/pypi/{UUID_B}")).is_dir(),
"{flavor}"
);
let ledger = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap();
assert!(
ledger.contains(UUID_B) && !ledger.contains(UUID),
"{flavor}: {ledger}"
);

// Re-running is settled: in sync, nothing rewritten.
let (code, env) = run_cli(&root, &["vendor"], &[]);
assert_eq!(code, 0, "{flavor}: {env:#}");
for (f, text) in files.iter().zip(&wired_b) {
assert_eq!(
&std::fs::read_to_string(root.join(f)).unwrap(),
text,
"{flavor}: {f}"
);
}

let (code, env) = run_cli(&root, &["vendor", "--revert"], &[]);
assert_eq!(code, 0, "{flavor}: revert after the re-vendor: {env:#}");
for (f, text) in files.iter().zip(&originals) {
assert_eq!(
&std::fs::read_to_string(root.join(f)).unwrap(),
text,
"{flavor}: {f} restored to the user's original"
);
}
assert!(
!root.join(format!(".socket/vendor/pypi/{UUID_B}")).exists(),
"{flavor}"
);
}
}

/// A Hatch guard unrelated to the old wiring (here the uv installer, which
/// Hatch reports under the same `pypi_hatch_unsupported` code, selected by
/// environment variable or by an environment's `installer` / `uv-path`
/// setting) refuses the superseding patch BEFORE patch A's wiring is
/// unwound: the project files, the ledger and patch A's artifact are left
/// exactly as they were, and no patch B wheel is built.
#[tokio::test]
async fn hatch_unrelated_guard_refuses_superseding_patch_before_unwinding() {
const UUID_B: &str = "5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6d";
const PATCHED_B: &[u8] = b"# six\nVERSION = '1.16.0'\nSOCKET_PATCHED = 2\n";
const UV_PATH: &[(&str, &str)] = &[("HATCH_ENV_TYPE_VIRTUAL_UV_PATH", "/usr/bin/uv")];
let cases = [
("env var", "", UV_PATH),
(
"installer",
"\n[tool.hatch.envs.default]\ninstaller = \"uv\"\n",
&[],
),
(
"uv-path",
"\n[tool.hatch.envs.default]\nuv-path = \"/usr/bin/uv\"\n",
&[],
),
];
for (case, setting, extra) in cases {
let (_tmp, root) = project();
let files = stage_hatch(&root);
vendor_project(&root, files);
if !setting.is_empty() {
let path = root.join("pyproject.toml");
let wired = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, wired + setting).unwrap();
}
let wired_a: Vec<String> = files
.iter()
.map(|f| std::fs::read_to_string(root.join(f)).unwrap())
.collect();
let ledger_a = std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap();

stage_manifest_with(&root, UUID_B, PATCHED_B);
let (code, env) = run_cli(&root, &["vendor"], extra);
assert_eq!(code, 1, "{case}: {env:#}");
let rendered = env.to_string();
assert!(
rendered.contains("pypi_hatch_unsupported") && rendered.contains("pip installer"),
"{case}: the installer guard is the reported refusal: {env:#}"
);
for (f, text) in files.iter().zip(&wired_a) {
assert_eq!(
&std::fs::read_to_string(root.join(f)).unwrap(),
text,
"{case}: {f} untouched"
);
}
assert_eq!(
std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap(),
ledger_a,
"{case}"
);
assert!(
root.join(format!(".socket/vendor/pypi/{UUID}")).is_dir(),
"{case}"
);
assert!(
!root.join(format!(".socket/vendor/pypi/{UUID_B}")).exists(),
"{case}"
);
}
}

/// The uv lock rewrite needs the hosted wheel's METADATA, fetched only
/// after the takeover reverted the vendored wiring. When it is unavailable
/// the package is left on the unpatched registry release in both modes, so
Expand Down
86 changes: 65 additions & 21 deletions crates/socket-patch-core/src/utils/hatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,49 @@ pub fn has_project_direct_references(files: &BTreeMap<String, String>) -> bool {
})
}

/// Refuse a project whose Hatch environments use the uv installer
/// (`installer = "uv"` or a non-empty `uv-path`): uv does not enforce local
/// wheel fragment hashes, so a vendored wheel needs pip.
pub fn require_pip_installer(files: &BTreeMap<String, String>) -> Result<(), String> {
let mut documents = Vec::new();
for file in HATCH_FILES {
if let Some(text) = files.get(file) {
documents.push(
text.parse::<DocumentMut>()
.map_err(|error| format!("{file}: {error}"))?,
);
}
}
require_pip_installer_documents(documents.iter())
}

fn require_pip_installer_documents<'a>(
documents: impl Iterator<Item = &'a DocumentMut>,
) -> Result<(), String> {
for document in documents {
let hatch = document
.get("tool")
.and_then(|tool| tool.get("hatch"))
.unwrap_or(document.as_item());
if hatch
.get("envs")
.and_then(Item::as_table_like)
.is_some_and(|envs| {
envs.iter().any(|(_, env)| {
env.get("installer").and_then(Item::as_str) == Some("uv")
|| env
.get("uv-path")
.and_then(Item::as_str)
.is_some_and(|path| !path.is_empty())
})
})
{
return Err("vendored Hatch wheels require the pip installer: uv does not enforce local wheel fragment hashes".into());
}
}
Ok(())
}

pub fn plan(
files: &BTreeMap<String, String>,
name: &str,
Expand All @@ -393,27 +436,7 @@ pub fn plan(
}
}
if url.starts_with("{root:uri}") {
for document in documents.values() {
let hatch = document
.get("tool")
.and_then(|tool| tool.get("hatch"))
.unwrap_or(document.as_item());
if hatch
.get("envs")
.and_then(Item::as_table_like)
.is_some_and(|envs| {
envs.iter().any(|(_, env)| {
env.get("installer").and_then(Item::as_str) == Some("uv")
|| env
.get("uv-path")
.and_then(Item::as_str)
.is_some_and(|path| !path.is_empty())
})
})
{
return Err("vendored Hatch wheels require the pip installer: uv does not enforce local wheel fragment hashes".into());
}
}
require_pip_installer_documents(documents.values())?;
}
let mut matched = 0;
let mut project_matched = 0;
Expand Down Expand Up @@ -684,6 +707,27 @@ mod tests {
}
}

#[test]
fn require_pip_installer_refuses_uv_settings_in_either_file() {
for setting in ["installer='uv'", "uv-path='uv'"] {
let project = files(&format!("[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\n{setting}\n"));
assert!(require_pip_installer(&project)
.unwrap_err()
.contains("pip installer"));
let external = both(
"[project]\ndependencies=[\"urllib3==1.26.18\"]",
Some(&format!("[envs.default]\n{setting}\n")),
);
assert!(require_pip_installer(&external)
.unwrap_err()
.contains("pip installer"));
}
for allowed in ["installer='pip'", "uv-path=''"] {
let project = files(&format!("[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\n{allowed}\n"));
assert!(require_pip_installer(&project).is_ok(), "{allowed}");
}
}

fn both(pyproject: &str, hatch: Option<&str>) -> BTreeMap<String, String> {
let mut inputs = files(pyproject);
if let Some(hatch) = hatch {
Expand Down
Loading
Loading