Skip to content
Open
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
140 changes: 140 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28211,3 +28211,143 @@ mod uv_old_release_advisories {
);
}
}

/// #639: a PEP 621 `dynamic = ["dependencies"]` project gets its dependency
/// list from the build backend, so pyproject.toml declares nothing uv's
/// root `requires-dist` can be restored from. Hosted mode must refuse to
/// repoint such an entry rather than write wiring that rollback, remove
/// and the vendored takeover can never undo.
#[cfg(test)]
mod uv_dynamic_dependencies {
use super::*;

const URL: &str = "https://patch.test/six-1.16.0-py2.py3-none-any.whl";
const SIX: &str = "[[package]]\nname = \"six\"\nversion = \"1.16.0\"\nsource = { registry = \"https://pypi.org/simple\" }\nwheels = [{ url = \"https://files.pythonhosted.org/packages/six-1.16.0-py2.py3-none-any.whl\", hash = \"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254\" }]\n";
const ISODATE: &str = "[[package]]\nname = \"isodate\"\nversion = \"0.6.1\"\nsource = { registry = \"https://pypi.org/simple\" }\ndependencies = [{ name = \"six\" }]\nwheels = [{ url = \"https://files.pythonhosted.org/packages/isodate-0.6.1-py2.py3-none-any.whl\", hash = \"sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96\" }]\n";

fn six() -> DepOverride {
DepOverride {
ecosystem: "pypi".into(),
name: "six".into(),
namespace: None,
version: "1.16.0".into(),
token: String::new(),
patch_uuid: "11111111-1111-4111-8111-111111111111".into(),
artifact_url: URL.into(),
registry_override: None,
integrity: Integrity {
sha256: Some("c".repeat(64)),
..Default::default()
},
}
}

/// The issue's setuptools project: `dynamic` names `dynamic_fields`,
/// and the backend reads the list from requirements.in.
fn project(dynamic_fields: &str) -> String {
format!(
"[project]\nname = \"app\"\nversion = \"1.0.0\"\nrequires-python = \">=3.8\"\ndynamic = [{dynamic_fields}]\n\n[build-system]\nrequires = [\"setuptools>=61\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.setuptools.dynamic]\ndependencies = {{ file = [\"requirements.in\"] }}\n"
)
}

fn lock(app_deps: &str, requires: &str, packages: &[&str]) -> String {
format!(
"version = 1\nrevision = 3\nrequires-python = \">=3.8\"\n\n[[package]]\nname = \"app\"\nversion = \"1.0.0\"\nsource = {{ editable = \".\" }}\ndependencies = [{app_deps}]\n\n[package.metadata]\nrequires-dist = [{requires}]\n\n{}",
packages.join("\n")
)
}

fn files(pyproject: String, uv_lock: String) -> BTreeMap<String, String> {
BTreeMap::from([
("pyproject.toml".to_string(), pyproject),
("uv.lock".to_string(), uv_lock),
])
}

/// The backend-supplied `six==1.16.0` is refused for every newline
/// style, for a dynamic optional-dependencies extra, and when a static
/// dependency group also names six (restore matches `requires-dist`
/// only against `[project]` declarations): nothing is written, and the
/// warning names the dynamic declaration.
#[test]
fn backend_declared_dependency_is_refused_before_writing() {
let six_dist = "{ name = \"six\", specifier = \"==1.16.0\" }";
let cases = [
(project("\"dependencies\""), six_dist),
(
project("\"optional-dependencies\""),
"{ name = \"six\", marker = \"extra == 'compat'\", specifier = \"==1.16.0\" }",
),
(
project("\"dependencies\"") + "\n[dependency-groups]\ntest = [\"six\"]\n",
six_dist,
),
];
for (pyproject, requires) in cases {
let dynamic = &pyproject;
for newline in ["\n", "\r\n"] {
let input = files(
pyproject.replace('\n', newline),
lock("{ name = \"six\" }", requires, &[SIX]).replace('\n', newline),
);
let result = rewrite_registry_redirect(&input, &[six()]);
assert!(
result.files.is_empty(),
"{dynamic}: wrote {:?}",
result.files.keys()
);
assert!(
result.refused_python_lock_uuids.contains(&six().patch_uuid)
&& !result
.confirmed_python_lock_uuids
.contains(&six().patch_uuid),
"{dynamic}: {:?}",
result.warnings
);
let warning = result
.warnings
.iter()
.find(|w| w.code == "redirect_uv_metadata_unsupported")
.unwrap_or_else(|| panic!("{dynamic}: {:?}", result.warnings));
assert!(
warning.detail.contains("six") && warning.detail.contains("dynamic"),
"{}",
warning.detail
);
}
}
}

/// A package the backend's list only pulls in transitively is not in
/// the root `requires-dist`, so its override wiring stays restorable
/// and the dynamic project is still hosted-patched.
#[test]
fn transitive_dependency_of_a_dynamic_project_is_still_wired() {
let input = files(
project("\"dependencies\""),
lock(
"{ name = \"isodate\" }",
"{ name = \"isodate\", specifier = \"==0.6.1\" }",
&[ISODATE, SIX],
),
);
let result = rewrite_registry_redirect(&input, &[six()]);
assert!(
result
.confirmed_python_lock_uuids
.contains(&six().patch_uuid),
"{:?}",
result.warnings
);
assert!(
result.files["pyproject.toml"].contains("override-dependencies"),
"{}",
result.files["pyproject.toml"]
);
assert!(
result.files["uv.lock"].contains(URL),
"{}",
result.files["uv.lock"]
);
}
}
77 changes: 68 additions & 9 deletions crates/socket-patch-core/src/utils/python_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,7 @@ fn prepare_metadata_completion(
let project: DocumentMut = project
.parse()
.map_err(|error| format!("invalid pyproject.toml: {error}"))?;
refuse_dynamic_root_requirement(document, &project, &name)?;
let overridden = project
.get("tool")
.and_then(Item::as_table_like)
Expand Down Expand Up @@ -718,6 +719,72 @@ fn prepare_metadata_completion(
}))
}

/// Refuse when `project` declares PEP 621 `dynamic` dependencies (or
/// optional-dependencies) and the lock's root package lists `name` in
/// `requires-dist` (#639). That entry came from the build backend, not from
/// pyproject.toml, so upstream restore has no declaration to derive its
/// specifier from, and rollback, remove and a vendored takeover could never
/// undo the repointed entry. `requires-dev` comes from PEP 735 groups,
/// which can't be dynamic, and a transitive package (absent from
/// `requires-dist`) is wired through an override that unwinds cleanly.
fn refuse_dynamic_root_requirement(
document: &DocumentMut,
project: &DocumentMut,
name: &str,
) -> Result<(), String> {
let dynamic: Vec<&str> = project
.get("project")
.and_then(|p| p.get("dynamic"))
.and_then(Item::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.filter(|field| ["dependencies", "optional-dependencies"].contains(field))
.collect();
if dynamic.is_empty() {
return Ok(());
}
let required = document
.get("package")
.and_then(Item::as_array_of_tables)
.into_iter()
.flatten()
.filter(|package| is_root_package(package))
.filter_map(|package| package.get("metadata"))
.filter_map(|metadata| metadata.get("requires-dist"))
.filter_map(Item::as_array)
.flatten()
.filter_map(Value::as_inline_table)
.filter_map(|requirement| requirement.get("name").and_then(Value::as_str))
.any(|value| canonicalize_pypi_name(value) == name);
if !required {
return Ok(());
}
let fields = dynamic
.iter()
.map(|field| format!("\"{field}\""))
.collect::<Vec<_>>()
.join(", ");
Err(format!(
"pyproject.toml declares dynamic = [{fields}], so the build backend supplies the \
project's {name} requirement; hosted mode does not redirect it, since rollback \
could not restore that requirement in the lock"
))
}

/// Whether `package` is the lock's root project (`source = { virtual = "." }`
/// or `{ editable = "." }`).
fn is_root_package(package: &Table) -> bool {
package
.get("source")
.and_then(Item::as_table_like)
.is_some_and(|source| {
["virtual", "editable"]
.into_iter()
.any(|key| source.get(key).and_then(Item::as_str) == Some("."))
})
}

fn finish_metadata_completion(
document: &mut DocumentMut,
completion: MetadataCompletion,
Expand All @@ -735,15 +802,7 @@ fn finish_metadata_completion(
.and_then(Item::as_array_of_tables_mut)
.expect("package collection checked");
for package in packages.iter_mut() {
let root = package
.get("source")
.and_then(Item::as_table_like)
.is_some_and(|source| {
["virtual", "editable"]
.into_iter()
.any(|key| source.get(key).and_then(Item::as_str) == Some("."))
});
if root {
if is_root_package(package) {
if let Some(metadata) = package
.get_mut("metadata")
.and_then(Item::as_table_like_mut)
Expand Down
11 changes: 11 additions & 0 deletions docs/testing/uv-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ frozen, locked, and ordinary installation outcomes separately where supported.
(`redirect_workspace_lockfile_elsewhere` hosted,
`pypi_uv_workspace_unsupported` vendored) instead of rewriting a
Hatch-configured member as a lockless Hatch project (#1138).
- A project with PEP 621 `dynamic = ["dependencies"]` (or
`"optional-dependencies"`) gets its requirement list from the build
backend, so `pyproject.toml` declares nothing the lock root's
`requires-dist` entry could be restored from. Vendored mode refuses
`dynamic = ["dependencies"]` outright (`pypi_uv_dynamic_dependencies`).
Hosted mode refuses, before any write, a package the lock root's
`requires-dist` lists in such a project
(`redirect_uv_metadata_unsupported`), since rollback and remove could
never restore that entry (#639). A package the project only pulls in
transitively is still wired through `override-dependencies`, which
rollback and remove undo.
- A script lock requires its paired script and a valid PEP 723 metadata block.
Missing metadata or an incompatible existing source is reported before either
file is rewritten.
Expand Down
Loading