Skip to content

Commit 7e64816

Browse files
mikolalysenkoclaude
andcommitted
Wire pyproject for Hatch-derived pylock files
Hatch 1.17+ locked environments write a pylock.toml that Hatch derives from pyproject and regenerates whenever the dependency hash changes. Both modes treated it as a standalone lock and rewrote only it, with `success` and no warning. Existing Hatch envs stayed unpatched, the next fresh env regenerated the lock from pyproject and dropped the patch, and vendored mode skipped the documented "Vendored Hatch requires the pip installer" refusal. A pylock in a Hatch project whose environments are locked (`lock-envs`, `locked`, `lock-filename`) is now Hatch's own: hosted mode wires the Hatch declarations in pyproject / hatch.toml as well as the lock, and warns `redirect_hatch_lock_regenerated` to run `hatch dep lock`. Vendored mode routes the project to the Hatch lane, so its guards (including the pip-installer refusal) apply. A pylock in a Hatch project without locked environments is still wired as a standalone lock. Fixes #479 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e314570 commit 7e64816

6 files changed

Lines changed: 255 additions & 1 deletion

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
13961396
| `redirect_vlt_lock_unsupported` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`redirect.skipped[].reason`). Exit 0. |
13971397
| `redirect_takeover_kept_vendored` | `redirect.warnings[]` (warning) | scan/get `--mode hosted`: a vendored → hosted takeover the hosted rewrite would not pin was retracted (see **Staged takeover**): the package keeps its vendored wiring, ledger entry and artifact byte-identical and stays patched. The detail names the cause code, which is also the purl's `redirect.skipped[].reason`. Exit 0. Replaces v5.0-pre `redirect_takeover_unpatched`, which reported a package left unpatched in both modes and is no longer emitted. |
13981398
| `redirect_takeover_not_pinned` | `redirect.skipped[].reason` | scan/get `--mode hosted`: the skip reason of a retracted takeover when no rewriter warning names the cause. |
1399+
| `redirect_hatch_lock_regenerated` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / Hatch 1.17+): the project's Hatch environments are locked (`lock-envs = true`, an environment's `locked = true` or `lock-filename`), so its `pylock*.toml` is derived from pyproject and Hatch regenerates it on the next sync. The package is wired in the Hatch declarations as well as in the lock (rewriting only the lock was discarded by Hatch, #479); run `hatch dep lock` to refresh the lock (`hatch dep lock --check` reports it stale until then). Vendored routes such a project to the Hatch lane and its pip-installer refusal. |
13991400
| `redirect_requirements_takeover_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / requirements.txt): a vendored → hosted takeover of a package that vendored mode wired through a pin in a `-r` include, or through a `(transitive)` line it appended to the root `requirements.txt`. Hosted mode only rewrites an existing pin in the root `requirements.txt`, so the staged takeover is retracted (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), move the pin from the include into the root `requirements.txt` and delete it from the include (or, for a `(transitive)` line, add an exact `==` pin to the root file), then re-run `scan --mode hosted`. |
14001401
| `redirect_uv_takeover_version_unreachable` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (pypi / uv): a vendored → hosted takeover of a package whose recorded pre-vendor `uv.lock` entry is at another version than the patch (vendored uv pins the lock entry down to the patch's version, for example when the lock resolved a newer release). Reverting would bring the lock's own version back, and hosted mode only pins the version the lock resolves, so the staged takeover is retracted (wet and `--dry-run`): the vendored wiring, ledger entry and wheel stay byte-identical, the purl is skipped with this code as `redirect.skipped[].reason`, and nothing is redirected for it. Exit 0. The detail names the remedy and its reach: run `socket-patch vendor --revert` (it reverts EVERY vendored package in the project, not just this one), make the project resolve the patch's version (for example an exact `==` requirement) and re-lock, then re-run `scan --mode hosted`. |
14011402
| `redirect_vlt_missing_sha512` / `redirect_vlt_entry_not_found` / `redirect_vlt_entry_vendored` / `redirect_vlt_unsupported_lock_key` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (vlt): the grant has no sha512 / the lock has no default-registry node for `name@version` / the only match is a vendored `file` node under `.socket/vendor/npm/<uuid>/` / a default-registry instance is outside vlt's node-line grammar or still unpatched after the splice. Per dep; none of the dep's instances is written. `redirect_vlt_missing_sha512` and `redirect_vlt_unsupported_lock_key` refuse the dep: it is never confirmed, whichever lock drives (a sibling lock may still carry its rewritten URL). `redirect_vlt_entry_not_found` and `redirect_vlt_entry_vendored` only say `vlt-lock.json` does not wire it: while vlt drives it is not confirmed; otherwise a sibling lock's rules may confirm it. Exit 0. |

‎crates/socket-patch-cli/tests/mode_migration_pypi.rs‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2002,3 +2002,61 @@ async fn pipenv_hosted_to_vendored_names_the_unpatched_requirements() {
20022002
"the takeover names requirements.txt as an unpatched install source: {env:#}"
20032003
);
20042004
}
2005+
2006+
// ── #479: a Hatch-derived pylock.toml ────────────────────────────────────
2007+
2008+
/// The pylock `hatch lock` writes for a locked environment (uv locker),
2009+
/// pinning six from PyPI.
2010+
const HATCH_PYLOCK: &str = "lock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.9\"\n\n[[packages]]\nname = \"six\"\nversion = \"1.16.0\"\nindex = \"https://pypi.org/simple\"\nsdist = { url = \"https://files.pythonhosted.org/packages/71/39/six-1.16.0.tar.gz\", upload-time = 2021-05-05T14:18:18Z, size = 34041, hashes = { sha256 = \"SDIST_SHA\" } }\nwheels = [{ url = \"https://files.pythonhosted.org/packages/d9/5a/six-1.16.0-py2.py3-none-any.whl\", upload-time = 2021-05-05T14:18:17Z, size = 11053, hashes = { sha256 = \"WHEEL_SHA\" } }]\n";
2011+
2012+
const HATCH_LOCKED_PYPROJECT: &str = "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n\n[tool.hatch.envs.default]\nlocked = true\ninstaller = \"uv\"\n";
2013+
2014+
/// #479: Hatch 1.17+ derives `pylock.toml` from pyproject for a locked
2015+
/// environment and regenerates it on the next sync, so a scan that rewrote
2016+
/// only the lock (success, no warning) never patched a Hatch environment.
2017+
/// Hosted must wire pyproject too (with a warning to re-lock); vendored must route to the Hatch lane,
2018+
/// whose uv-installer refusal applies, and write nothing.
2019+
#[tokio::test]
2020+
async fn hatch_locked_env_pylock_wires_pyproject() {
2021+
let pylock = HATCH_PYLOCK
2022+
.replace("SDIST_SHA", SDIST_SHA)
2023+
.replace("WHEEL_SHA", WHEEL_SHA);
2024+
let server = MockServer::start().await;
2025+
let hosted_url = mount_hosted_api(&server, true).await;
2026+
2027+
let (_tmp, root) = project();
2028+
std::fs::write(root.join("pyproject.toml"), HATCH_LOCKED_PYPROJECT).unwrap();
2029+
std::fs::write(root.join("pylock.toml"), &pylock).unwrap();
2030+
let (code, env) = hosted_scan(&root, &server);
2031+
assert_eq!(code, 0, "hosted: {env:#}");
2032+
assert_eq!(env["redirect"]["redirected"], 1, "{env:#}");
2033+
let pyproject = std::fs::read_to_string(root.join("pyproject.toml")).unwrap();
2034+
assert!(
2035+
pyproject.contains(&format!("six @ {hosted_url}")),
2036+
"pyproject is wired:\n{pyproject}"
2037+
);
2038+
let lock = std::fs::read_to_string(root.join("pylock.toml")).unwrap();
2039+
assert!(
2040+
lock.contains(&hosted_url),
2041+
"the lock stays consistent with pyproject until Hatch regenerates it:\n{lock}"
2042+
);
2043+
assert!(
2044+
env.to_string().contains("redirect_hatch_lock_regenerated"),
2045+
"{env:#}"
2046+
);
2047+
let (_tmp, root) = project();
2048+
std::fs::write(root.join("pyproject.toml"), HATCH_LOCKED_PYPROJECT).unwrap();
2049+
std::fs::write(root.join("pylock.toml"), &pylock).unwrap();
2050+
stage_manifest(&root);
2051+
let (code, env) = run_cli(&root, &["vendor"], &[]);
2052+
assert_ne!(code, 0, "vendored: {env:#}");
2053+
assert!(env.to_string().contains("pip installer"), "{env:#}");
2054+
assert_eq!(
2055+
std::fs::read_to_string(root.join("pyproject.toml")).unwrap(),
2056+
HATCH_LOCKED_PYPROJECT
2057+
);
2058+
assert_eq!(
2059+
std::fs::read_to_string(root.join("pylock.toml")).unwrap(),
2060+
pylock
2061+
);
2062+
}

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

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1021,14 +1021,25 @@ fn rewrite_hatch(
10211021
.filter(|dep| dep.ecosystem == "pypi")
10221022
.map(|dep| dep.patch_uuid.clone()),
10231023
);
1024+
// A pylock Hatch derives from pyproject (locked environments, #479) is
1025+
// not an install source of its own: Hatch regenerates it from the
1026+
// pyproject, so this lane wires pyproject too (the python-lock lane
1027+
// still rewrites the lock, keeping the two consistent until Hatch
1028+
// regenerates it from the wired pyproject).
10241029
if files.keys().any(|file| {
10251030
matches!(
10261031
file.as_str(),
10271032
"uv.lock" | "poetry.lock" | "pdm.lock" | "Pipfile.lock"
1028-
) || crate::utils::python_lock::is_python_lock_name(file)
1033+
) || (crate::utils::python_lock::is_python_lock_name(file)
1034+
&& !crate::utils::hatch::is_hatch_lock(files, file))
10291035
}) {
10301036
return;
10311037
}
1038+
let hatch_locks: Vec<&str> = files
1039+
.keys()
1040+
.filter(|file| crate::utils::hatch::is_hatch_lock(files, file))
1041+
.map(String::as_str)
1042+
.collect();
10321043
if files.contains_key("requirements.txt") {
10331044
result
10341045
.confirmed_hatch_uuids
@@ -1058,6 +1069,20 @@ fn rewrite_hatch(
10581069
match crate::utils::hatch::rewrite(&current, &dep.name, &dep.version, &url) {
10591070
Ok(edits) => {
10601071
result.confirmed_hatch_uuids.insert(dep.patch_uuid.clone());
1072+
if !edits.is_empty() && !hatch_locks.is_empty() {
1073+
result.warnings.push(RewriteWarning {
1074+
code: "redirect_hatch_lock_regenerated".into(),
1075+
detail: format!(
1076+
"{}@{}: wired in the Hatch configuration as well as in {}, which \
1077+
Hatch derives from it (locked environments) and regenerates on \
1078+
the next environment sync; run `hatch dep lock` to refresh it \
1079+
now (`hatch dep lock --check` reports it stale until then)",
1080+
dep.name,
1081+
dep.version,
1082+
hatch_locks.join(", ")
1083+
),
1084+
});
1085+
}
10611086
for (path, new) in edits {
10621087
result.edits.push(FileEdit {
10631088
path: path.clone(),
@@ -23686,6 +23711,77 @@ mod hatch_tests {
2368623711
}
2368723712
}
2368823713

23714+
/// The pylock `hatch lock` (Hatch 1.17+, `created-by = "uv"`) writes for
23715+
/// a locked environment, pinning urllib3 from PyPI.
23716+
const HATCH_PYLOCK: &str = "lock-version = \"1.0\"\ncreated-by = \"uv\"\nrequires-python = \">=3.9\"\n\n[[packages]]\nname = \"urllib3\"\nversion = \"1.26.18\"\nindex = \"https://pypi.org/simple\"\nwheels = [{ url = \"https://files.pythonhosted.org/packages/b0/urllib3-1.26.18-py2.py3-none-any.whl\", upload-time = 2023-10-17T17:46:21Z, size = 143835, hashes = { sha256 = \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\" } }]\n";
23717+
23718+
/// #479: a Hatch project whose environments are locked carries a
23719+
/// `pylock*.toml` Hatch DERIVES from pyproject (and regenerates on the
23720+
/// next sync), so hosted mode wires pyproject — the declaration Hatch
23721+
/// locks and installs from — beside the lock, with a warning, instead
23722+
/// of rewriting only the lock (which Hatch then threw away).
23723+
#[test]
23724+
fn hatch_locked_env_pylock_also_wires_pyproject() {
23725+
let head = "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"urllib3==1.26.18\"]\n";
23726+
for (config, lock) in [
23727+
(
23728+
"\n[tool.hatch.envs.default]\nlocked = true\ninstaller = \"uv\"\n",
23729+
"pylock.toml",
23730+
),
23731+
("\n[tool.hatch]\nlock-envs = true\n", "pylock.toml"),
23732+
(
23733+
"\n[tool.hatch.envs.test]\nlocked = true\n",
23734+
"pylock.test.toml",
23735+
),
23736+
] {
23737+
let files: BTreeMap<String, String> = [
23738+
("pyproject.toml".to_string(), format!("{head}{config}")),
23739+
(lock.to_string(), HATCH_PYLOCK.to_string()),
23740+
]
23741+
.into_iter()
23742+
.collect();
23743+
let result = rewrite_registry_redirect(&files, &[patch()]);
23744+
assert!(
23745+
result.confirmed_hatch_uuids.contains("test-uuid"),
23746+
"{config}: {:?}",
23747+
result.warnings
23748+
);
23749+
assert!(
23750+
result.files["pyproject.toml"].contains(&format!(
23751+
"urllib3 @ {}#sha256={}",
23752+
patch().artifact_url,
23753+
"a".repeat(64)
23754+
)),
23755+
"{config}"
23756+
);
23757+
// The lock stays consistent with pyproject until Hatch
23758+
// regenerates it from the wired declaration.
23759+
assert!(
23760+
result.files[lock].contains(&patch().artifact_url),
23761+
"{config}"
23762+
);
23763+
assert!(
23764+
result
23765+
.warnings
23766+
.iter()
23767+
.any(|w| w.code == "redirect_hatch_lock_regenerated" && w.detail.contains(lock)),
23768+
"{config}: {:?}",
23769+
result.warnings
23770+
);
23771+
}
23772+
// Without locked environments a pylock is its own install source
23773+
// (`uv export`, `pip lock`): the lock lane keeps it, pyproject stays.
23774+
let files: BTreeMap<String, String> = [
23775+
("pyproject.toml".to_string(), head.to_string()),
23776+
("pylock.toml".to_string(), HATCH_PYLOCK.to_string()),
23777+
]
23778+
.into_iter()
23779+
.collect();
23780+
let result = rewrite_registry_redirect(&files, &[patch()]);
23781+
assert!(!result.files.contains_key("pyproject.toml"));
23782+
assert!(result.confirmed_hatch_uuids.is_empty());
23783+
}
23784+
2368923785
#[test]
2369023786
fn hatch_confirmation_ignores_inactive_sources_and_comments() {
2369123787
let dep = patch();

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,47 @@ pub fn is_hatch(files: &BTreeMap<String, String>) -> bool {
2828
})
2929
}
3030

31+
/// Whether the project's Hatch configuration locks environments (Hatch
32+
/// 1.17+: `lock-envs = true`, or an environment with `locked = true` or a
33+
/// `lock-filename`), in pyproject's `[tool.hatch]` or hatch.toml.
34+
fn locks_environments(files: &BTreeMap<String, String>) -> bool {
35+
HATCH_FILES.into_iter().any(|file| {
36+
let Some(document) = parsed(files, file) else {
37+
return false;
38+
};
39+
let hatch = if file == HATCH_FILES[1] {
40+
Some(document.as_item())
41+
} else {
42+
document.get("tool").and_then(|tool| tool.get("hatch"))
43+
};
44+
let Some(hatch) = hatch else {
45+
return false;
46+
};
47+
hatch.get("lock-envs").and_then(Item::as_bool) == Some(true)
48+
|| hatch
49+
.get("envs")
50+
.and_then(Item::as_table_like)
51+
.is_some_and(|envs| {
52+
envs.iter().any(|(_, env)| {
53+
env.get("locked").and_then(Item::as_bool) == Some(true)
54+
|| env.get("lock-filename").is_some()
55+
})
56+
})
57+
})
58+
}
59+
60+
/// Whether `path` is a PEP 751 lock Hatch derives from pyproject (#479):
61+
/// a `pylock.toml` / `pylock.<name>.toml` in a Hatch project whose
62+
/// environments are locked. Hatch regenerates it from pyproject whenever
63+
/// the dependency hash changes, so it is not an independent install
64+
/// source: the Hatch wiring (pyproject) is what must be rewritten, and a
65+
/// rewrite of only the lock is thrown away by the next environment sync.
66+
pub fn is_hatch_lock(files: &BTreeMap<String, String>, path: &str) -> bool {
67+
crate::utils::python_lock::is_pep751_lock_name(path)
68+
&& is_hatch(files)
69+
&& locks_environments(files)
70+
}
71+
3172
/// One PEP 508 dependency string Hatch installs.
3273
pub(crate) struct HatchSpec<'d> {
3374
/// `pyproject.toml` or `hatch.toml` (a [`HATCH_FILES`] entry).

‎crates/socket-patch-core/src/vendor/pypi.rs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,20 @@ async fn detect_pypi_flavor(
301301
tool_locks.contains(&lock)
302302
}));
303303
}
304+
// #479: a pylock Hatch derives from pyproject (locked environments) is
305+
// regenerated by Hatch on the next sync, so it is not a lock to wire on
306+
// its own; the project routes to the Hatch lane (and its guards).
307+
let mut hatch_files = std::collections::BTreeMap::new();
308+
for file in crate::utils::hatch::HATCH_FILES {
309+
if let Ok(text) = read_regular_to_string(&project_root.join(file)).await {
310+
hatch_files.insert(file.to_string(), text);
311+
}
312+
}
304313
let additional_locks: Vec<String> = crate::utils::python_lock::python_lock_paths(project_root)
305314
.map_err(|error| ("pypi_lock_read_failed", error.to_string()))?
306315
.into_iter()
307316
.filter(|path| path != "uv.lock")
317+
.filter(|path| !crate::utils::hatch::is_hatch_lock(&hatch_files, path))
308318
.collect();
309319
// #912 / #1122: a pylock beside a `Pipfile` belongs to Pipenv, not to a
310320
// PEP 751 installer. With a `Pipfile.lock` Pipenv installs from that
@@ -2865,6 +2875,42 @@ mod tests {
28652875
}
28662876
}
28672877

2878+
/// #479: in a Hatch project with locked environments, the pylock is
2879+
/// Hatch's own derived lock: it routes to the Hatch flavor (whose guards
2880+
/// refuse the uv installer locked environments use) instead of the
2881+
/// python-lock lane, which wired only the lock Hatch then regenerated.
2882+
/// Without locked environments the same pylock still routes to it.
2883+
#[tokio::test]
2884+
async fn hatch_locked_env_pylock_routes_to_hatch() {
2885+
let head = "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n";
2886+
for (lock, config) in [
2887+
(
2888+
"pylock.toml",
2889+
"\n[tool.hatch.envs.default]\nlocked = true\ninstaller = \"uv\"\n",
2890+
),
2891+
("pylock.test.toml", "\n[tool.hatch]\nlock-envs = true\n"),
2892+
] {
2893+
let tmp = tempfile::tempdir().unwrap();
2894+
touch(tmp.path(), lock, PIPENV_PYLOCK).await;
2895+
touch(tmp.path(), "pyproject.toml", head).await;
2896+
let (selected, _) = detect_pypi_flavor(tmp.path(), Some(("six", "1.16.0")))
2897+
.await
2898+
.unwrap();
2899+
assert_eq!(selected, PypiFlavor::PythonLocks, "{lock}: no locked envs");
2900+
touch(tmp.path(), "pyproject.toml", &format!("{head}{config}")).await;
2901+
let (selected, warnings) = detect_pypi_flavor(tmp.path(), Some(("six", "1.16.0")))
2902+
.await
2903+
.unwrap();
2904+
assert_eq!(selected, PypiFlavor::Hatch, "{lock}");
2905+
assert!(
2906+
warnings
2907+
.iter()
2908+
.all(|w| w.code != "pypi_unmatched_lockfiles"),
2909+
"{lock}: {warnings:?}"
2910+
);
2911+
}
2912+
}
2913+
28682914
/// #1120: a `requirements.txt` exported beside the governing lock in
28692915
/// UTF-16 (`uv export > requirements.txt` in Windows PowerShell 5.1)
28702916
/// is installed by pip and uv like its UTF-8 twin, so it is a loud

0 commit comments

Comments
 (0)