@@ -175,6 +175,12 @@ pub struct RewriteResult {
175175 /// An incomplete pnpm rewrite must not be confirmed by finding its URL
176176 /// in another instance, a comment, or another lockfile.
177177 pub refused_pnpm_uuids: std::collections::BTreeSet<String>,
178+ pub python_lock_uuids: std::collections::BTreeSet<String>,
179+ pub confirmed_python_lock_uuids: std::collections::BTreeSet<String>,
180+ pub refused_python_lock_uuids: std::collections::BTreeSet<String>,
181+ pub hatch_uuids: std::collections::BTreeSet<String>,
182+ pub confirmed_hatch_uuids: std::collections::BTreeSet<String>,
183+ pub confirmed_requirements_uuids: std::collections::BTreeSet<String>,
178184}
179185
180186/// Combined name as it appears in registry coordinates / lock keys.
@@ -215,6 +221,7 @@ pub fn rewrite_registry_redirect_with_python_metadata(
215221 rewrite_yarn_berry(files, overrides, &mut result);
216222 rewrite_bun_lock(files, overrides, &mut result);
217223 rewrite_pypi_requirements(files, overrides, &mut result);
224+ rewrite_hatch(files, overrides, &mut result);
218225 rewrite_uv_lock(files, overrides, python_metadata, &mut result);
219226 poetry::rewrite_poetry(files, overrides, &mut result);
220227 rewrite_cargo(files, overrides, &mut result);
@@ -226,6 +233,72 @@ pub fn rewrite_registry_redirect_with_python_metadata(
226233 result
227234}
228235
236+ fn rewrite_hatch(
237+ files: &BTreeMap<String, String>,
238+ overrides: &[DepOverride],
239+ result: &mut RewriteResult,
240+ ) {
241+ if !crate::utils::hatch::is_hatch(files) {
242+ return;
243+ }
244+ result.hatch_uuids.extend(
245+ overrides
246+ .iter()
247+ .filter(|dep| dep.ecosystem == "pypi")
248+ .map(|dep| dep.patch_uuid.clone()),
249+ );
250+ if files.keys().any(|file| {
251+ matches!(
252+ file.as_str(),
253+ "uv.lock" | "poetry.lock" | "pdm.lock" | "Pipfile.lock"
254+ ) || crate::utils::python_lock::is_python_lock_name(file)
255+ }) {
256+ return;
257+ }
258+ if files.contains_key("requirements.txt") {
259+ result
260+ .confirmed_hatch_uuids
261+ .extend(result.confirmed_requirements_uuids.iter().cloned());
262+ return;
263+ }
264+ let mut current = files.clone();
265+ for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") {
266+ let Some(hash) =
267+ dep.integrity.sha256.as_ref().filter(|hash| {
268+ hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
269+ })
270+ else {
271+ result.warnings.push(RewriteWarning {
272+ code: "redirect_hatch_missing_sha256".into(),
273+ detail: format!("{} has no valid wheel digest", dep.name),
274+ });
275+ continue;
276+ };
277+ let url = format!("{}#sha256={hash}", dep.artifact_url);
278+ match crate::utils::hatch::rewrite(¤t, &dep.name, &dep.version, &url) {
279+ Ok(edits) => {
280+ result.confirmed_hatch_uuids.insert(dep.patch_uuid.clone());
281+ for (path, new) in edits {
282+ result.edits.push(FileEdit {
283+ path: path.clone(),
284+ kind: "redirect_hatch_document".into(),
285+ action: "rewritten".into(),
286+ key: Some(format!("{}@{}", dep.name, dep.version)),
287+ original: current.get(&path).cloned().map(Value::String),
288+ new: Some(Value::String(new.clone())),
289+ });
290+ current.insert(path.clone(), new.clone());
291+ result.files.insert(path, new);
292+ }
293+ }
294+ Err(detail) => result.warnings.push(RewriteWarning {
295+ code: "redirect_hatch_unsupported".into(),
296+ detail,
297+ }),
298+ }
299+ }
300+ }
301+
229302// ── npm package-lock.json / npm-shrinkwrap.json ─────────────────────────────
230303fn rewrite_npm_lock(
231304 files: &BTreeMap<String, String>,
@@ -2587,6 +2660,7 @@ fn rewrite_uv_lock(
25872660 // missing-integrity warning three times.
25882661 let mut usable: Vec<(&DepOverride, &str)> = Vec::new();
25892662 for dep in overrides.iter().filter(|dep| dep.ecosystem == "pypi") {
2663+ result.python_lock_uuids.insert(dep.patch_uuid.clone());
25902664 match dep.integrity.sha256.as_deref() {
25912665 Some(sha256) => usable.push((dep, sha256)),
25922666 None => result.warnings.push(RewriteWarning {
@@ -2614,6 +2688,7 @@ fn rewrite_uv_lock(
26142688 continue;
26152689 }
26162690 Err(detail) => {
2691+ result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
26172692 result.warnings.push(RewriteWarning {
26182693 code: "redirect_uv_lock_unsupported".into(),
26192694 detail: format!("{path}: {detail}"),
@@ -2625,6 +2700,7 @@ fn rewrite_uv_lock(
26252700 match plan_python_metadata(path, &content, files, dep, result) {
26262701 Ok(plan) => plan,
26272702 Err(warning) => {
2703+ result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
26282704 result.warnings.push(warning);
26292705 continue;
26302706 }
@@ -2639,13 +2715,15 @@ fn rewrite_uv_lock(
26392715 ) {
26402716 Ok(rewritten) => rewritten,
26412717 Err(detail) => {
2718+ result.refused_python_lock_uuids.insert(dep.patch_uuid.clone());
26422719 result.warnings.push(RewriteWarning {
26432720 code: "redirect_uv_metadata_unsupported".into(),
26442721 detail: format!("{path}: {detail}"),
26452722 });
26462723 continue;
26472724 }
26482725 };
2726+ result.confirmed_python_lock_uuids.insert(dep.patch_uuid.clone());
26492727 if let Some(edit) = metadata_edit {
26502728 record_python_metadata_edit(edit, dep, result);
26512729 }
@@ -13132,3 +13210,107 @@ mod python_lock_warning_tests {
1313213210 );
1313313211 }
1313413212}
13213+
13214+ #[cfg(test)]
13215+ mod hatch_tests {
13216+ use super::*;
13217+
13218+ fn patch() -> DepOverride {
13219+ DepOverride {
13220+ ecosystem: "pypi".into(),
13221+ name: "urllib3".into(),
13222+ namespace: None,
13223+ version: "1.26.18".into(),
13224+ token: String::new(),
13225+ patch_uuid: "test-uuid".into(),
13226+ artifact_url: "https://patch.test/urllib3-1.26.18-py2.py3-none-any.whl".into(),
13227+ berry_zip_url: None,
13228+ registry_override: None,
13229+ integrity: Integrity {
13230+ sha256: Some("a".repeat(64)),
13231+ ..Default::default()
13232+ },
13233+ }
13234+ }
13235+
13236+ #[test]
13237+ fn hatch_confirmation_ignores_inactive_sources_and_comments() {
13238+ let dep = patch();
13239+ let files = [
13240+ ("pyproject.toml".into(), format!("[project]\ndependencies=[]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3 @ {}#sha256={}\"]\n", dep.artifact_url, "a".repeat(64))),
13241+ ("hatch.toml".into(), format!("[envs.default]\ndependencies=[\"urllib3>=1\"]\n# {}\n", dep.artifact_url)),
13242+ ].into_iter().collect();
13243+ let result = rewrite_registry_redirect(&files, &[dep]);
13244+ assert!(result.hatch_uuids.contains("test-uuid"));
13245+ assert!(result.confirmed_hatch_uuids.is_empty());
13246+ assert!(result.files.is_empty());
13247+ assert!(result
13248+ .warnings
13249+ .iter()
13250+ .any(|warning| warning.code == "redirect_hatch_unsupported"));
13251+ let mut files = files;
13252+ files.insert("requirements.txt".into(), String::new());
13253+ let result = rewrite_registry_redirect(&files, &[patch()]);
13254+ assert!(result.hatch_uuids.contains("test-uuid"));
13255+ assert!(result.confirmed_hatch_uuids.is_empty());
13256+ files.insert("requirements.txt".into(), "urllib3==1.26.18\n".into());
13257+ let result = rewrite_registry_redirect(&files, &[patch()]);
13258+ assert!(result.confirmed_hatch_uuids.contains("test-uuid"));
13259+ }
13260+
13261+ #[test]
13262+ fn hatch_confirmation_uses_successful_lock_writers() {
13263+ let base: BTreeMap<String, String> = [
13264+ ("pyproject.toml".into(), format!("[project]\ndependencies=[]\n[tool.hatch.envs.default]\ndependencies=[\"urllib3 @ {}\"]\n", patch().artifact_url)),
13265+ ("hatch.toml".into(), "[envs.default]\ndependencies=[\"urllib3>=1\"]\n".into()),
13266+ ].into_iter().collect();
13267+ for (filename, text) in [
13268+ ("uv.lock", "version = 2"),
13269+ ("pylock.toml", "lock-version = '2.0'"),
13270+ ("pdm.lock", "[metadata]\nlock_version = '4.5.1'"),
13271+ ("Pipfile.lock", "{}"),
13272+ (
13273+ "poetry.lock",
13274+ include_str!("../../../tests/fixtures/poetry/0.12.17/poetry.lock"),
13275+ ),
13276+ ] {
13277+ let mut files = base.clone();
13278+ files.insert(filename.into(), text.into());
13279+ let result = rewrite_registry_redirect(&files, &[patch()]);
13280+ assert!(result.hatch_uuids.contains("test-uuid"), "{filename}");
13281+ assert!(result.confirmed_hatch_uuids.is_empty(), "{filename}");
13282+ assert!(result.confirmed_python_lock_uuids.is_empty(), "{filename}");
13283+ }
13284+ let mut files = base;
13285+ files.insert(
13286+ "poetry.lock".into(),
13287+ include_str!("../../../tests/fixtures/poetry/1.0.10/poetry.lock").into(),
13288+ );
13289+ let result = rewrite_registry_redirect(&files, &[patch()]);
13290+ assert!(result.confirmed_python_lock_uuids.contains("test-uuid"));
13291+ assert!(result.refused_python_lock_uuids.is_empty());
13292+ files.extend(result.files);
13293+ let result = rewrite_registry_redirect(&files, &[patch()]);
13294+ assert!(result.confirmed_python_lock_uuids.contains("test-uuid"));
13295+ assert!(result.files.is_empty());
13296+ files.insert("uv.lock".into(), "version = 2".into());
13297+ let result = rewrite_registry_redirect(&files, &[patch()]);
13298+ assert!(result.refused_python_lock_uuids.contains("test-uuid"));
13299+ }
13300+
13301+ #[test]
13302+ fn hatch_confirmation_requires_success_and_reruns_stay_confirmed() {
13303+ let files = [(
13304+ "pyproject.toml".into(),
13305+ "[project]\ndependencies=[\"urllib3==1.26.18\"]\n[tool.hatch.envs.default]\n".into(),
13306+ )]
13307+ .into_iter()
13308+ .collect();
13309+ let result = rewrite_registry_redirect(&files, &[patch()]);
13310+ assert!(result.confirmed_hatch_uuids.contains("test-uuid"));
13311+ let second = rewrite_registry_redirect(&result.files, &[patch()]);
13312+ assert!(second.confirmed_hatch_uuids.contains("test-uuid"));
13313+ assert!(second.files.is_empty());
13314+ }
13315+ }
13316+
0 commit comments