Skip to content
Merged
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
45 changes: 45 additions & 0 deletions crates/socket-patch-cli/tests/mode_migration_pypi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,51 @@ async fn uv_vendored_to_hosted() {
assert_vendored_to_hosted(&root, files).await;
}

/// #564: under `no-sources = true` (pyproject's `[tool.uv]` or a project
/// `uv.toml`) uv ignores `[tool.uv.sources]`, so a wired source fails
/// `uv sync --locked` and a plain `uv sync` installs the unpatched wheel.
/// Both modes used to wire it and exit 0; they must refuse and write
/// nothing.
#[tokio::test]
async fn uv_no_sources_is_refused_in_both_modes() {
for (file, setting) in [
("pyproject.toml", "\n[tool.uv]\nno-sources = true\n"),
("uv.toml", "no-sources = true\n"),
] {
let (_tmp, root) = project();
stage_uv(&root);
let path = root.join(file);
let mut text = std::fs::read_to_string(&path).unwrap_or_default();
text.push_str(setting);
std::fs::write(&path, text).unwrap();
let before = tree(&root);

let server = MockServer::start().await;
mount_hosted_api(&server, true).await;
let (code, env) = hosted_scan(&root, &server);
// Skipped with the refusal as its warning, like the other uv
// project shapes hosted mode cannot wire.
assert_eq!(code, 0, "hosted {file}: {env:#}");
assert_eq!(hosted_pinned(&env), 0, "hosted {file}: {env:#}");
assert!(
env.to_string().contains("redirect_uv_project_unsupported")
&& env.to_string().contains("no-sources"),
"hosted {file}: {env:#}"
);
assert_eq!(tree(&root), before, "hosted {file} wrote nothing");

stage_manifest(&root);
let before = tree(&root);
let (code, env) = run_cli(&root, &["vendor"], &[]);
assert_ne!(code, 0, "vendored {file}: {env:#}");
assert!(
env.to_string().contains("pypi_uv_no_sources"),
"vendored {file}: {env:#}"
);
assert_eq!(tree(&root), before, "vendored {file} wrote nothing");
}
}

/// A Hatch project; returns its wiring files.
fn stage_hatch(root: &Path) -> &'static [&'static str] {
std::fs::write(
Expand Down
15 changes: 15 additions & 0 deletions crates/socket-patch-core/src/hosted/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,21 @@ pub async fn read_candidate_files(
out.read(view, unreadable, &path).await;
}

// Beside a uv project or script lock, the project's `uv.toml` may set
// `no-sources`, under which uv ignores the `[tool.uv.sources]` entry the
// rewrite would add (#564). Advisory: never rewritten, so a link or an
// unreadable in-memory entry is left out rather than refused.
if out
.files
.keys()
.any(|path| path == "uv.lock" || crate::utils::python_lock::is_script_lock_name(path))
{
let rel = crate::utils::python_script::UV_TOML;
if let Some(text) = read_advisory(view, unreadable, rel).await {
out.files.insert(rel.to_string(), text);
}
}

// Rush monorepos have no root package.json/lock pair: the single pnpm
// source-of-truth lock lives at common/config/rush/pnpm-lock.yaml, and
// (when subspaces are enabled) one lock per subspace under
Expand Down
158 changes: 158 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5782,6 +5782,18 @@ fn plan_python_metadata_with(
detail: format!("{metadata_path}: {detail}"),
};
source_scope().map_err(unsupported)?;
let project_file = |rel: &str| files.get(rel).map(String::as_str);
crate::utils::python_script::refuse_uv_no_sources(
&dep.name,
script.then_some(original.as_str()),
if script {
project_file("pyproject.toml")
} else {
Some(&original)
},
project_file(crate::utils::python_script::UV_TOML),
)
.map_err(unsupported)?;
let rewritten = if script {
rewrite_script_metadata(
&original,
Expand Down Expand Up @@ -25356,6 +25368,152 @@ mod python_metadata_pairing_tests {
};
assert_eq!(bad.code, "redirect_uv_project_unsupported");
}

/// #564: under `no-sources = true` uv ignores every `[tool.uv.sources]`
/// entry, so a wired source fails `uv sync --locked` and a plain
/// `uv sync` installs the unpatched registry wheel. The planner refuses
/// before writing. A project `uv.toml` replaces pyproject's `[tool.uv]`
/// settings, while a script's own `[tool.uv]` wins over `uv.toml`.
#[test]
fn no_sources_refuses_a_source_uv_would_ignore() {
let refused = |path: &str, files: &[(&str, &str)]| match plan(path, files, &[]) {
Err(warning) => {
assert!(warning.detail.contains("no-sources"), "{}", warning.detail);
warning.code
}
Ok(_) => panic!("{path} with {files:?} must refuse"),
};
let wired = |path: &str, files: &[(&str, &str)]| {
let (edit, _) =
plan(path, files, &[]).unwrap_or_else(|w| panic!("{}: {}", w.code, w.detail));
assert!(edit.is_some(), "{path} with {files:?} must be wired");
};
let project_no_sources = format!("{PYPROJECT}\n[tool.uv]\nno-sources = true\n");
let script_no_sources = |value: bool| {
SCRIPT.replace(
"# ///\n",
&format!("#\n# [tool.uv]\n# no-sources = {value}\n# ///\n"),
)
};
let script_true = script_no_sources(true);
let script_false = script_no_sources(false);

assert_eq!(
refused("uv.lock", &[("pyproject.toml", &project_no_sources)]),
"redirect_uv_project_unsupported"
);
assert_eq!(
refused(
"uv.lock",
&[
("pyproject.toml", PYPROJECT),
("uv.toml", "no-sources = true\n")
]
),
"redirect_uv_project_unsupported"
);
assert_eq!(
refused("tool.py.lock", &[("tool.py", &script_true)]),
"redirect_uv_script_unsupported"
);
assert_eq!(
refused(
"tool.py.lock",
&[("tool.py", SCRIPT), ("uv.toml", "no-sources = true\n")]
),
"redirect_uv_script_unsupported"
);

// uv.toml without the key overrides pyproject's [tool.uv] entirely.
wired(
"uv.lock",
&[
("pyproject.toml", &project_no_sources),
("uv.toml", "offline = false\n"),
],
);
wired(
"uv.lock",
&[
("pyproject.toml", PYPROJECT),
("uv.toml", "no-sources = false\n"),
],
);
// The script's own setting wins over uv.toml.
wired(
"tool.py.lock",
&[
("tool.py", &script_false),
("uv.toml", "no-sources = true\n"),
],
);
// A script without its own setting takes the project's: uv.toml,
// else pyproject's [tool.uv].
assert_eq!(
refused(
"tool.py.lock",
&[("tool.py", SCRIPT), ("pyproject.toml", &project_no_sources)]
),
"redirect_uv_script_unsupported"
);
wired(
"tool.py.lock",
&[
("tool.py", SCRIPT),
("pyproject.toml", &project_no_sources),
("uv.toml", "offline = false\n"),
],
);
// A uv.toml that does not parse sets nothing (uv reports it).
wired(
"uv.lock",
&[
("pyproject.toml", PYPROJECT),
("uv.toml", "no-sources = [\n"),
],
);

// no-sources-package drops the source of the packages it names
// (normalized), in either file; a script's list combines with the
// project's, and an explicit no-sources = false disables it.
let names = |list: &str| format!("{PYPROJECT}\n[tool.uv]\nno-sources-package = {list}\n");
assert_eq!(
refused("uv.lock", &[("pyproject.toml", &names("[\"Click\"]"))]),
"redirect_uv_project_unsupported"
);
assert_eq!(
refused(
"uv.lock",
&[
("pyproject.toml", PYPROJECT),
("uv.toml", "no-sources-package = [\"click\"]\n")
]
),
"redirect_uv_project_unsupported"
);
wired("uv.lock", &[("pyproject.toml", &names("[\"requests\"]"))]);
let script_names = SCRIPT.replace(
"# ///\n",
"#\n# [tool.uv]\n# no-sources-package = [\"requests\"]\n# ///\n",
);
assert_eq!(
refused(
"tool.py.lock",
&[
("tool.py", &script_names),
("uv.toml", "no-sources-package = [\"click\"]\n")
]
),
"redirect_uv_script_unsupported"
);
wired(
"tool.py.lock",
&[
("tool.py", &script_false),
("uv.toml", "no-sources-package = [\"click\"]\n"),
],
);
}
}

#[cfg(test)]
Expand Down
87 changes: 86 additions & 1 deletion crates/socket-patch-core/src/utils/python_script.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::ops::Range;

use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, TableLike, Value};

use crate::crawlers::python_crawler::canonicalize_pypi_name;
use crate::utils::python_lock::ArtifactSource;
Expand Down Expand Up @@ -304,6 +304,91 @@ fn contains_dependency(item: Option<&Item>, name: &str) -> bool {
})
}

/// The project-level uv settings file, read beside `pyproject.toml` (and
/// beside a PEP 723 script, from uv's working directory).
pub const UV_TOML: &str = "uv.toml";

/// Refuse to wire a `[tool.uv.sources]` entry for `name` that uv would
/// ignore (#564).
///
/// Under `no-sources = true`, or a `no-sources-package` list naming the
/// package, uv drops the source, so a wired entry fails `uv sync --locked`,
/// and a plain `uv sync` relocks the package to the registry and installs
/// the unpatched wheel. `script` is the PEP 723 script being wired (`None`
/// for a project), and `pyproject` and `uv_toml` are the project's files,
/// when present. Each file's settings are uv's (checked against uv 0.11): a
/// `uv.toml` replaces pyproject's `[tool.uv]` outright; a script's own
/// `no-sources` wins over the project's, while the two `no-sources-package`
/// lists combine; and an explicit `no-sources = false` disables the list.
/// A file that does not parse sets nothing here: uv reports it itself, and
/// the rewriters report a metadata file they cannot edit.
pub fn refuse_uv_no_sources(
name: &str,
script: Option<&str>,
pyproject: Option<&str>,
uv_toml: Option<&str>,
) -> Result<(), String> {
struct Settings {
file: &'static str,
no_sources: Option<bool>,
packages: Vec<String>,
}
fn settings(file: &'static str, toml: &str, path: &[&str]) -> Option<Settings> {
let document: DocumentMut = toml.parse().ok()?;
let mut table = document.as_table() as &dyn TableLike;
for key in path {
table = table.get(key)?.as_table_like()?;
}
Some(Settings {
file,
no_sources: table.get("no-sources").and_then(Item::as_bool),
packages: table
.get("no-sources-package")
.and_then(Item::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(canonicalize_pypi_name)
.collect(),
})
}
let script = script.and_then(|text| {
let (_, metadata) = script_metadata(text).ok()?;
settings("the script's [tool.uv]", &metadata, &["tool", "uv"])
});
let project = match uv_toml {
Some(text) => settings(UV_TOML, text, &[]),
None => {
pyproject.and_then(|text| settings("pyproject.toml [tool.uv]", text, &["tool", "uv"]))
}
};
let levels = [script, project];
let levels = levels.iter().flatten();
let refusal = |file: &str, setting: &str| {
Err(format!(
"{file} sets {setting}, so uv ignores the [tool.uv.sources] entry and a plain \
`uv sync` would install the unpatched registry wheel; remove the setting to \
wire a patch"
))
};
match levels
.clone()
.find_map(|level| Some((level.file, level.no_sources?)))
{
Some((file, true)) => return refusal(file, "no-sources = true"),
Some((_, false)) => return Ok(()),
None => {}
}
let name = canonicalize_pypi_name(name);
match levels
.into_iter()
.find(|level| level.packages.contains(&name))
{
Some(level) => refusal(level.file, &format!("no-sources-package naming {name}")),
None => Ok(()),
}
}

pub fn rewrite_project_metadata(
text: &str,
name: &str,
Expand Down
Loading
Loading