Skip to content

Commit 22162d7

Browse files
Fix vendored Hatch member breaking workspace root (#505) (#1724)
* Start fix for #505 Assisted-by: Claude Code:claude-opus-5-5 * Refuse vendoring a Hatch workspace member A vendored scan run from a member of a Hatch 1.16+ workspace wrote {root:uri} references into the member's [project] dependencies and reported success. Hatch's workspace environment reads a member's static dependencies raw, so every sync of the workspace root env then failed with pip's "No connection adapters were found for '{root:uri}/...'" until someone hand-edited the member. Detect a workspace environment declared in an ancestor pyproject.toml or hatch.toml (up to the checkout root) that lists the project, the way Hatch resolves members (globs, path tables, exclude, features and self-referencing extras), and refuse the package with pypi_hatch_unsupported before anything is written, pointing at hosted or agent mode. Dependencies the workspace does not read raw (the member's own env deps, unselected extras, dynamic metadata) are still vendored. Fixes #505 Assisted-by: Claude Code:claude-opus-5-5 * Find sibling Hatch workspace roots and more specs Review follow-ups for the workspace-member refusal: - look for a workspace root anywhere in the checkout, since Hatch lets a root list a member beside it through a ../ spec - read members written as an array of tables and specs using the {root} and {home} context fields - stop at the checkout root even when the member is that root - match fnmatch patterns in linear time, and fold case and separators on Windows the way Python's fnmatch does - look a selected feature up by its exact key, as Hatch does - tell a user whose member was vendored by an earlier run how to undo it - share the hatch.toml/pyproject env-table rule with the planner Assisted-by: Claude Code:claude-opus-5-5 * Bound Hatch workspace reads like repo lookups Read workspace candidates through the non-blocking regular-file reader, so a FIFO at a pyproject.toml or hatch.toml path can't hang a vendored scan, and walk ancestors with the shared repository lookup, which stops at the checkout root, never enters the home directory and honours GIT_CEILING_DIRECTORIES. Assisted-by: Claude Code:claude-opus-5-5 * Match Hatch members reached through symlinks Resolve each workspace member spec by listing directories the way Hatch's find_members does, following symlinked directories, and compare what it finds with the scanned project by real path. Roots are looked for above both the spelled and the real path, so a member Hatch reaches through a symlink is refused whichever path the scan was given. Assisted-by: Claude Code:claude-opus-5-5 * Count member extras named across a Hatch workspace Hatch's all_dependencies_complex expands a workspace member's raw optional dependencies whenever any of the environment's requirement strings names the member with an extra: the root's own dependencies, its environments', or another member's (lib[s3]). Those groups reach pip raw, so a vendored {root:uri} there breaks the workspace just like a selected feature does. Count those extras as consumed. Also say in the Hatch notes that a sibling root listing the member through ../ is found as well. Assisted-by: Claude Code:claude-opus-5-5 * Read workspace extras the way Hatch installs them Collect the requirement strings that name the member from what the workspace environment actually installs: the root's dependencies and its environment features' extras, the environment's and its templates' dependencies (from hatch.toml when that replaces the pyproject tables), and the other members' dependencies and selected features. Resolve the extras they name through the same normalized lookup Hatch's resolve_extras uses, so a mixed-case extra name is found and strings Hatch never installs select nothing. Assisted-by: Claude Code:claude-opus-5-5 * Treat every extra of a Hatch workspace member as raw Which optional dependencies a Hatch workspace environment installs from a member depends on the whole workspace: the member's features, <member>[extra] references from the root, environments, their templates and dependency groups, and extras of other members that reach it. Each review round found another path the precise model missed, and a miss writes a {root:uri} that breaks every workspace sync. Once a workspace lists the member, count all its static dependencies and optional dependencies as read raw instead. A refusal still leaves hosted and agent mode, and the module shrinks to member discovery. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2253e0 commit 22162d7

6 files changed

Lines changed: 826 additions & 13 deletions

File tree

‎crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,3 +740,88 @@ fn hatch_existing_env_hosted() {
740740
fn hatch_existing_env_vendored() {
741741
existing_env_flow(Mode::Vendored);
742742
}
743+
744+
/// #505: a vendored scan run from a member of a Hatch 1.16+ workspace whose
745+
/// root environment installs the member's `[project]` dependencies (read
746+
/// raw, so `{root:uri}` would reach pip unexpanded) is refused with
747+
/// `pypi_hatch_unsupported` and writes nothing; the workspace root's real
748+
/// `hatch env create` still succeeds afterwards.
749+
#[test]
750+
#[ignore = "real Hatch + PyPI; run with --ignored (CI: SOCKET_PATCH_HATCH_E2E_REQUIRED=1)"]
751+
fn hatch_workspace_member_vendored_is_refused() {
752+
let Some(hatch) = hatch() else { return };
753+
let version = hatch.version.clone();
754+
if vtuple(&version) < (1, 16) {
755+
eprintln!("SKIP: Hatch {version} has no workspaces");
756+
return;
757+
}
758+
let what = format!("hatch {version} workspace-member/vendored");
759+
let tmp = tempfile::tempdir().unwrap();
760+
let ws = tmp.path().join("ws");
761+
let member = ws.join("libs/lib1");
762+
std::fs::create_dir_all(ws.join("src/app")).unwrap();
763+
std::fs::create_dir_all(member.join("src/lib1")).unwrap();
764+
std::fs::write(ws.join("src/app/__init__.py"), "").unwrap();
765+
std::fs::write(member.join("src/lib1/__init__.py"), "").unwrap();
766+
let root_pyproject = format!(
767+
"{BUILD}[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = []\n\n\
768+
[tool.hatch.build.targets.wheel]\npackages = [\"src/app\"]\n\n\
769+
[tool.hatch.envs.default.workspace]\nmembers = [\"libs/*\"]\n"
770+
);
771+
let member_pyproject = format!(
772+
"{BUILD}[project]\nname = \"lib1\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n\n\
773+
[tool.hatch.build.targets.wheel]\npackages = [\"src/lib1\"]\n"
774+
);
775+
std::fs::write(ws.join("pyproject.toml"), &root_pyproject).unwrap();
776+
std::fs::write(member.join("pyproject.toml"), &member_pyproject).unwrap();
777+
778+
let pristine_venv = member.join(".venv");
779+
if let Err(e) = make_venv(&hatch.uv, &hatch.python(), &pristine_venv, &["six==1.16.0"]) {
780+
skip_or_fail(REQUIRED, &format!("{what}: six==1.16.0 from PyPI: {e}"));
781+
return;
782+
}
783+
let (_, pristine, _) =
784+
six_oracle(&venv_bin(&pristine_venv, "python"), &member).expect("pristine six");
785+
let patched = [pristine.as_slice(), PATCH_SUFFIX].concat();
786+
let api = RealApi::start(Mode::Vendored.uuid(), &pristine, &patched);
787+
let scan_envs = vec![("PATH".to_string(), hatch.bin_path())];
788+
let (code, env, stderr) =
789+
socket_scan(&member, &api, &scan_mode_args(Mode::Vendored), &scan_envs);
790+
assert_ne!(code, Some(0), "{what}: must refuse: {env}\n{stderr}");
791+
let events: Vec<&serde_json::Value> = env["events"].as_array().into_iter().flatten().collect();
792+
assert!(
793+
events
794+
.iter()
795+
.any(|e| e["errorCode"] == "pypi_hatch_unsupported"
796+
&& e.to_string().contains("Hatch workspace")),
797+
"{what}: {env}\n{stderr}"
798+
);
799+
assert_eq!(
800+
std::fs::read_to_string(member.join("pyproject.toml")).unwrap(),
801+
member_pyproject,
802+
"{what}: member pyproject must be untouched"
803+
);
804+
assert_eq!(
805+
std::fs::read_to_string(ws.join("pyproject.toml")).unwrap(),
806+
root_pyproject,
807+
"{what}: root pyproject must be untouched"
808+
);
809+
let _ = std::fs::remove_dir_all(&pristine_venv);
810+
811+
// The workspace root env still syncs from the untouched member.
812+
let case = tmp.path().join("case");
813+
let envs = hatch.case_env(&case);
814+
let out = hatch.run(&ws, &envs, &["env", "create"]);
815+
assert!(
816+
out.status.success(),
817+
"{what}: root `hatch env create`: {}",
818+
String::from_utf8_lossy(&out.stderr)
819+
);
820+
record(
821+
"hatch",
822+
&version,
823+
"workspace-member/vendored",
824+
"refused",
825+
"pass",
826+
);
827+
}

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,26 @@ pub(crate) fn dependency_specs<'d>(
138138
specs
139139
}
140140

141+
/// The `envs` item Hatch reads and the [`HATCH_FILES`] entry holding it:
142+
/// hatch.toml's when that document has an `envs` key (Hatch merges an
143+
/// external config by TOP-LEVEL key, so it replaces pyproject's whole
144+
/// table), else pyproject's `[tool.hatch.envs]`.
145+
pub(crate) fn environments<'d>(
146+
pyproject: Option<&'d DocumentMut>,
147+
hatch_toml: Option<&'d DocumentMut>,
148+
) -> (&'static str, Option<&'d Item>) {
149+
match hatch_toml.filter(|d| d.contains_key("envs")) {
150+
Some(doc) => (HATCH_FILES[1], doc.get("envs")),
151+
None => (
152+
HATCH_FILES[0],
153+
pyproject
154+
.and_then(|d| d.get("tool"))
155+
.and_then(|t| t.get("hatch"))
156+
.and_then(|h| h.get("envs")),
157+
),
158+
}
159+
}
160+
141161
/// The Hatch environment tables: `envs` of the hatch.toml document, else of
142162
/// pyproject's `[tool.hatch]` (Hatch merges an external config by TOP-LEVEL
143163
/// key, so a hatch.toml `envs` key — whatever its value — replaces
@@ -146,18 +166,8 @@ fn environment_specs<'d>(
146166
pyproject: Option<&'d DocumentMut>,
147167
hatch_toml: Option<&'d DocumentMut>,
148168
) -> Vec<HatchSpec<'d>> {
149-
let (file, hatch) = match hatch_toml.filter(|d| d.contains_key("envs")) {
150-
Some(doc) => (HATCH_FILES[1], Some(doc.as_item())),
151-
None => (
152-
HATCH_FILES[0],
153-
pyproject
154-
.and_then(|d| d.get("tool"))
155-
.and_then(|t| t.get("hatch")),
156-
),
157-
};
158-
hatch
159-
.and_then(|h| h.get("envs"))
160-
.and_then(Item::as_table_like)
169+
let (file, envs) = environments(pyproject, hatch_toml);
170+
envs.and_then(Item::as_table_like)
161171
.into_iter()
162172
.flat_map(|envs| envs.iter())
163173
.flat_map(|(_, env)| {

0 commit comments

Comments
 (0)