Skip to content

Commit 43638b2

Browse files
Fix uv script no-sources in a parent config (#1501) (#1504)
* Start fix for #1501 Assisted-by: Claude Code:claude-opus-5-5 * Refuse uv scripts under a parent no-sources A PEP 723 script takes uv's settings from the nearest directory at or above it holding a uv.toml or a pyproject.toml with [tool.uv]. When a script was scanned from a subdirectory (scripts/ under a project whose root sets no-sources = true), both modes read only the scanned root, wired the script and exited 0. uv then ignored the source: `uv run --locked` failed and a plain `uv run` imported the unpatched package. The script lane now walks above the root when the root sets nothing, the way uv does, and refuses with the parent file named (vendored pypi_uv_no_sources, hosted redirect_uv_script_unsupported). The project lane is unchanged, since a project stops uv's lookup at itself. Fixes #1501 Assisted-by: Claude Code:claude-opus-5-5 * Document the uv script parent config lookup Note in the #564 bullet that a script's project settings come from the nearest uv.toml or [tool.uv] at or above it (#1501). Assisted-by: Claude Code:claude-opus-5-5 * Read parent uv settings safely and only if needed The parent lookup now skips the walk when the scanned root's own uv.toml or [tool.uv] already governs the script, opens each file through the FIFO-safe regular-file reader, and stops at a settings file that exists but cannot be read, the way uv does, instead of going on to a farther one. Assisted-by: Claude Code:claude-opus-5-5 * Walk past an unparseable pyproject like uv uv warns about a pyproject.toml it cannot parse during settings discovery and goes on to the parent directory. The script lookup treated such a file as governing, so a broken pyproject beside the script or in between hid a parent uv.toml that sets no-sources, and the script was wired anyway. Assisted-by: Claude Code:claude-opus-5-5 * Test a workspace member script stays unwired In a uv workspace, uv takes a member's script settings from the workspace root, so a member's own [tool.uv] does not shield the script from the root's no-sources. The #1138 workspace-member guard already refuses that run in both modes before the script lookup; this pins it. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 51ba2f3 commit 43638b2

6 files changed

Lines changed: 538 additions & 32 deletions

File tree

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,105 @@ async fn uv_no_sources_is_refused_in_both_modes() {
864864
}
865865
}
866866

867+
/// #1501: a PEP 723 script takes uv's settings from the nearest directory
868+
/// at or above it that holds a `uv.toml` or a `pyproject.toml` with
869+
/// `[tool.uv]`. A parent project's `no-sources = true` therefore drops the
870+
/// script's source too, so both modes must refuse a script scanned from a
871+
/// subdirectory just as they do beside the setting. A parent pyproject
872+
/// without `[tool.uv]` sets nothing, and the script is still wired.
873+
#[tokio::test]
874+
async fn uv_script_no_sources_in_a_parent_is_refused_in_both_modes() {
875+
let parent_pyproject = "[project]\nname = \"root\"\nversion = \"0.1.0\"\n";
876+
for (file, setting, refused) in [
877+
("uv.toml", "no-sources = true\n".to_string(), true),
878+
(
879+
"pyproject.toml",
880+
format!("{parent_pyproject}\n[tool.uv]\nno-sources = true\n"),
881+
true,
882+
),
883+
(
884+
"pyproject.toml",
885+
format!("{parent_pyproject}\n[tool.uv]\nno-sources-package = [\"Six\"]\n"),
886+
true,
887+
),
888+
("pyproject.toml", parent_pyproject.to_string(), false),
889+
] {
890+
let (_tmp, root) = project();
891+
stage_script_lock(&root);
892+
std::fs::write(root.parent().unwrap().join(file), &setting).unwrap();
893+
let before = tree(&root);
894+
895+
let server = MockServer::start().await;
896+
mount_hosted_api(&server, true).await;
897+
let (code, env) = hosted_scan(&root, &server);
898+
assert_eq!(code, 0, "hosted {file}: {env:#}");
899+
if refused {
900+
assert_eq!(hosted_pinned(&env), 0, "hosted {file}: {env:#}");
901+
assert!(
902+
env.to_string().contains("redirect_uv_script_unsupported")
903+
&& env.to_string().contains(&format!("../{file}")),
904+
"hosted {file}: {env:#}"
905+
);
906+
assert_eq!(tree(&root), before, "hosted {file} wrote nothing");
907+
} else {
908+
assert_eq!(hosted_pinned(&env), 1, "hosted {file}: {env:#}");
909+
continue;
910+
}
911+
912+
stage_manifest(&root);
913+
let before = tree(&root);
914+
let (code, env) = run_cli(&root, &["vendor"], &[]);
915+
assert_ne!(code, 0, "vendored {file}: {env:#}");
916+
assert!(
917+
env.to_string().contains("pypi_uv_no_sources")
918+
&& env.to_string().contains(&format!("../{file}")),
919+
"vendored {file}: {env:#}"
920+
);
921+
assert_eq!(tree(&root), before, "vendored {file} wrote nothing");
922+
}
923+
}
924+
925+
/// In a uv workspace, uv takes a member's script settings from the
926+
/// workspace root, so a member's own `[tool.uv]` does not shield the script
927+
/// from the root's `no-sources`. The #1138 workspace-member guard refuses
928+
/// the run before the script lane's own lookup, in both modes.
929+
#[tokio::test]
930+
async fn uv_script_in_a_workspace_member_is_not_wired() {
931+
let (_tmp, root) = project();
932+
stage_script_lock(&root);
933+
std::fs::write(
934+
root.join("pyproject.toml"),
935+
"[project]\nname = \"s\"\nversion = \"0.1.0\"\n\n[tool.uv]\npackage = false\n",
936+
)
937+
.unwrap();
938+
std::fs::write(
939+
root.parent().unwrap().join("pyproject.toml"),
940+
"[project]\nname = \"b\"\nversion = \"0.1.0\"\n\n[tool.uv.workspace]\nmembers = [\"proj\"]\n\n[tool.uv]\nno-sources = true\n",
941+
)
942+
.unwrap();
943+
let before = tree(&root);
944+
945+
let server = MockServer::start().await;
946+
mount_hosted_api(&server, true).await;
947+
let (code, env) = hosted_scan(&root, &server);
948+
assert_eq!(code, 1, "hosted: {env:#}");
949+
assert_eq!(
950+
env["error"]["code"], "redirect_workspace_lockfile_elsewhere",
951+
"hosted: {env:#}"
952+
);
953+
assert_eq!(tree(&root), before, "hosted wrote nothing");
954+
955+
stage_manifest(&root);
956+
let before = tree(&root);
957+
let (code, env) = run_cli(&root, &["vendor"], &[]);
958+
assert_ne!(code, 0, "vendored: {env:#}");
959+
assert!(
960+
env.to_string().contains("pypi_uv_workspace_unsupported"),
961+
"vendored: {env:#}"
962+
);
963+
assert_eq!(tree(&root), before, "vendored wrote nothing");
964+
}
965+
867966
/// A Hatch project; returns its wiring files.
868967
fn stage_hatch(root: &Path) -> &'static [&'static str] {
869968
std::fs::write(

‎crates/socket-patch-core/src/hosted/engine.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,33 @@ pub async fn read_candidate_files(
714714
out.files.insert(rel.to_string(), text);
715715
}
716716
}
717+
// A PEP 723 script also takes uv's settings from above the root when
718+
// the root holds none (#1501); only a disk view has an above.
719+
if out
720+
.files
721+
.keys()
722+
.any(|path| crate::utils::python_lock::is_script_lock_name(path))
723+
&& !crate::utils::python_script::root_governs_script(
724+
out.files.get("pyproject.toml").map(String::as_str),
725+
out.files
726+
.get(crate::utils::python_script::UV_TOML)
727+
.map(String::as_str),
728+
)
729+
{
730+
let files = view
731+
.disk_root_reading(std::iter::empty::<&std::path::Path>())
732+
.map(crate::utils::python_script::ancestor_uv_config_files)
733+
.unwrap_or_default();
734+
if let Some(config) = view
735+
.disk_root_reading(files.iter().map(|(_, path)| path))
736+
.and_then(crate::utils::python_script::find_ancestor_uv_config)
737+
{
738+
out.files.insert(
739+
crate::patch::redirect::UV_ANCESTOR_CONFIG_KEY.to_string(),
740+
format!("{}\n{}", config.rel, config.text),
741+
);
742+
}
743+
}
717744

718745
// Rush monorepos have no root package.json/lock pair: the single pnpm
719746
// source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and

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

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5948,16 +5948,29 @@ fn plan_python_metadata_with(
59485948
};
59495949
source_scope().map_err(unsupported)?;
59505950
let project_file = |rel: &str| files.get(rel).map(String::as_str);
5951-
crate::utils::python_script::refuse_uv_no_sources(
5952-
&dep.name,
5953-
script.then_some(original.as_str()),
5954-
if script {
5955-
project_file("pyproject.toml")
5956-
} else {
5957-
Some(&original)
5958-
},
5959-
project_file(crate::utils::python_script::UV_TOML),
5960-
)
5951+
if script {
5952+
let ancestor = files.get(UV_ANCESTOR_CONFIG_KEY).and_then(|value| {
5953+
let (rel, text) = value.split_once('\n')?;
5954+
Some(crate::utils::python_script::UvConfig {
5955+
rel: rel.to_string(),
5956+
text: text.to_string(),
5957+
})
5958+
});
5959+
crate::utils::python_script::refuse_uv_script_no_sources(
5960+
&dep.name,
5961+
&original,
5962+
project_file("pyproject.toml"),
5963+
project_file(crate::utils::python_script::UV_TOML),
5964+
ancestor.as_ref(),
5965+
)
5966+
} else {
5967+
crate::utils::python_script::refuse_uv_no_sources(
5968+
&dep.name,
5969+
None,
5970+
Some(&original),
5971+
project_file(crate::utils::python_script::UV_TOML),
5972+
)
5973+
}
59615974
.map_err(unsupported)?;
59625975
let rewritten = if script {
59635976
rewrite_script_metadata(
@@ -6349,6 +6362,15 @@ pub const NUGET_INHERITED_MAPPING_KEY: &str = "<socket-patch:nuget-inherited-map
63496362
/// configs dropped nuget.org.
63506363
const EMPTY_NUGET_CONFIG: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<configuration>\n <packageSources>\n </packageSources>\n</configuration>\n";
63516364

6365+
/// The synthetic candidate key carrying the uv settings file above the
6366+
/// root that uv applies to a PEP 723 script there (#1501), as
6367+
/// `<rel>\n<text>` ([`find_ancestor_uv_config`]). Absent (the in-memory
6368+
/// engine, unit tests, no such file), only the root's own files are read.
6369+
/// Never a path (see [`sbt::SYNTHETIC_KEY_PREFIX`]).
6370+
///
6371+
/// [`find_ancestor_uv_config`]: crate::utils::python_script::find_ancestor_uv_config
6372+
pub const UV_ANCESTOR_CONFIG_KEY: &str = "<socket-patch:uv-ancestor-config>";
6373+
63526374
/// The synthetic candidate key carrying the locks the engine found every
63536375
/// project under the root restoring into (#353, #514), one per line:
63546376
/// `lock\t<rel>`, `unresolved\t<project>\t<why>` for a `NuGetLockFilePath`
@@ -25990,6 +26012,69 @@ mod python_metadata_pairing_tests {
2599026012
],
2599126013
);
2599226014
}
26015+
26016+
/// #1501: a script takes uv's settings from above the root when the
26017+
/// root sets none; the engine hands that file over under
26018+
/// [`UV_ANCESTOR_CONFIG_KEY`]. The root's own settings stop the lookup,
26019+
/// and a project lane never reads it.
26020+
#[test]
26021+
fn no_sources_above_the_root_refuses_a_script() {
26022+
let parent = |rel: &str, text: &str| format!("{rel}\n{text}");
26023+
let uv_toml = parent("../uv.toml", "no-sources = true\n");
26024+
let Err(warning) = plan(
26025+
"tool.py.lock",
26026+
&[("tool.py", SCRIPT), (UV_ANCESTOR_CONFIG_KEY, &uv_toml)],
26027+
&[],
26028+
) else {
26029+
panic!("a parent uv.toml must refuse");
26030+
};
26031+
assert_eq!(warning.code, "redirect_uv_script_unsupported");
26032+
assert!(
26033+
warning.detail.contains("../uv.toml sets no-sources = true"),
26034+
"{}",
26035+
warning.detail
26036+
);
26037+
let pyproject = parent("../../pyproject.toml", "[tool.uv]\nno-sources = true\n");
26038+
let Err(warning) = plan(
26039+
"tool.py.lock",
26040+
&[
26041+
("tool.py", SCRIPT),
26042+
("pyproject.toml", PYPROJECT),
26043+
(UV_ANCESTOR_CONFIG_KEY, &pyproject),
26044+
],
26045+
&[],
26046+
) else {
26047+
panic!("a parent pyproject [tool.uv] must refuse");
26048+
};
26049+
assert!(
26050+
warning
26051+
.detail
26052+
.contains("../../pyproject.toml [tool.uv] sets no-sources = true"),
26053+
"{}",
26054+
warning.detail
26055+
);
26056+
26057+
let wired = |path: &str, files: &[(&str, &str)]| {
26058+
let (edit, _) =
26059+
plan(path, files, &[]).unwrap_or_else(|w| panic!("{}: {}", w.code, w.detail));
26060+
assert!(edit.is_some(), "{path} with {files:?} must be wired");
26061+
};
26062+
wired(
26063+
"tool.py.lock",
26064+
&[
26065+
("tool.py", SCRIPT),
26066+
("uv.toml", "offline = false\n"),
26067+
(UV_ANCESTOR_CONFIG_KEY, &uv_toml),
26068+
],
26069+
);
26070+
wired(
26071+
"uv.lock",
26072+
&[
26073+
("pyproject.toml", PYPROJECT),
26074+
(UV_ANCESTOR_CONFIG_KEY, &uv_toml),
26075+
],
26076+
);
26077+
}
2599326078
}
2599426079

2599526080
#[cfg(test)]

0 commit comments

Comments
 (0)