diff --git a/crates/uv-resolver/src/pubgrub/dependencies.rs b/crates/uv-resolver/src/pubgrub/dependencies.rs index 881a4396f8b5c..24c596a3b88d6 100644 --- a/crates/uv-resolver/src/pubgrub/dependencies.rs +++ b/crates/uv-resolver/src/pubgrub/dependencies.rs @@ -2,7 +2,6 @@ use std::iter; use either::Either; use pubgrub::Ranges; -use tracing::warn; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; @@ -91,42 +90,23 @@ impl PubGrubDependency { // Add the package, plus any extra variants. iter.map(|(extra, group)| PubGrubRequirement::from_requirement(requirement, extra, group)) - .filter_map(move |requirement| { + .map(move |requirement| { let PubGrubRequirement { package, version, url, } = requirement; match &*package { - PubGrubPackageInner::Package { name, .. } => { - // Detect self-dependencies. - if dev.is_none() { - if source_name.is_some_and(|source_name| source_name == name) { - warn!("{name} has a dependency on itself"); - return None; - } - } - - Some(PubGrubDependency { - package: package.clone(), - version: version.clone(), - url, - }) - } - PubGrubPackageInner::Marker { name, .. } => { - // Detect self-dependencies. - if dev.is_none() { - if source_name.is_some_and(|source_name| source_name == name) { - return None; - } - } - - Some(PubGrubDependency { - package: package.clone(), - version: version.clone(), - url, - }) - } + PubGrubPackageInner::Package { .. } => PubGrubDependency { + package: package.clone(), + version: version.clone(), + url, + }, + PubGrubPackageInner::Marker { .. } => PubGrubDependency { + package: package.clone(), + version: version.clone(), + url, + }, PubGrubPackageInner::Extra { name, .. } => { // Detect self-dependencies. if dev.is_none() { @@ -135,11 +115,11 @@ impl PubGrubDependency { "extras not flattened for {name}" ); } - Some(PubGrubDependency { + PubGrubDependency { package: package.clone(), version: version.clone(), url, - }) + } } PubGrubPackageInner::Dev { name, .. } => { // Detect self-dependencies. @@ -149,11 +129,11 @@ impl PubGrubDependency { "group not flattened for {name}" ); } - Some(PubGrubDependency { + PubGrubDependency { package: package.clone(), version: version.clone(), url, - }) + } } PubGrubPackageInner::Root(_) => unreachable!("root package in dependencies"), PubGrubPackageInner::Python(_) => { diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs index 32e55d2d63c2a..7e773c2cefbd3 100644 --- a/crates/uv-resolver/src/pubgrub/report.rs +++ b/crates/uv-resolver/src/pubgrub/report.rs @@ -133,6 +133,16 @@ impl ReportFormatter, UnavailableReason> External::FromDependencyOf(package, package_set, dependency, dependency_set) => { let package_set = self.simplify_set(package_set, package); let dependency_set = self.simplify_set(dependency_set, dependency); + + if package == dependency { + if let Some(member) = self.format_workspace_member(package) { + return format!( + "{member} depends on itself at an incompatible version ({})", + PackageRange::dependency(package, &dependency_set, None) + ); + } + } + if let Some(root) = self.format_root_requires(package) { return format!( "{root} {}", @@ -407,6 +417,24 @@ impl PubGrubReportFormatter<'_> { } } + /// Return whether the given package is the root package. + fn is_root(package: &PubGrubPackage) -> bool { + matches!(&**package, PubGrubPackageInner::Root(_)) + } + + /// Return whether the given package is a workspace member. + fn is_single_project_workspace_member(&self, package: &PubGrubPackage) -> bool { + match &**package { + // TODO(zanieb): Improve handling of dev and extra for single-project workspaces + PubGrubPackageInner::Package { + name, extra, dev, .. + } if self.workspace_members.contains(name) => { + self.is_single_project_workspace() && extra.is_none() && dev.is_none() + } + _ => false, + } + } + /// Create a [`PackageRange::compatibility`] display with this formatter attached. fn compatible_range<'a>( &'a self, @@ -467,6 +495,18 @@ impl PubGrubReportFormatter<'_> { .and(dependency2.package, &dependency_set2), ) } + (.., External::FromDependencyOf(package, _, dependency, _)) + if Self::is_root(package) + && self.is_single_project_workspace_member(dependency) => + { + self.format_external(external1) + } + (External::FromDependencyOf(package, _, dependency, _), ..) + if Self::is_root(package) + && self.is_single_project_workspace_member(dependency) => + { + self.format_external(external2) + } _ => { let external1 = self.format_external(external1); let external2 = self.format_external(external2); @@ -570,6 +610,16 @@ impl PubGrubReportFormatter<'_> { workspace: self.is_workspace() && !self.is_single_project_workspace(), }); } + + if package_name == dependency_name + && (dependency.extra().is_none() || package.extra() == dependency.extra()) + && (dependency.dev().is_none() || dependency.dev() == package.dev()) + && workspace_members.contains(package_name) + { + output_hints.insert(PubGrubHint::DependsOnItself { + package: package.clone(), + }); + } } // Check for no versions due to `Requires-Python`. if matches!( @@ -899,6 +949,8 @@ pub(crate) enum PubGrubHint { dependency: PubGrubPackage, workspace: bool, }, + /// A package depends on itself at an incompatible version. + DependsOnItself { package: PubGrubPackage }, /// A package was available on an index, but not at the correct version, and at least one /// subsequent index was not queried. As such, a compatible version may be available on an /// one of the remaining indexes. @@ -963,6 +1015,9 @@ enum PubGrubHintCore { dependency: PubGrubPackage, workspace: bool, }, + DependsOnItself { + package: PubGrubPackage, + }, UncheckedIndex { package: PubGrubPackage, }, @@ -1027,6 +1082,7 @@ impl From for PubGrubHintCore { dependency, workspace, }, + PubGrubHint::DependsOnItself { package } => Self::DependsOnItself { package }, PubGrubHint::UncheckedIndex { package, .. } => Self::UncheckedIndex { package }, PubGrubHint::UnauthorizedIndex { index } => Self::UnauthorizedIndex { index }, PubGrubHint::ForbiddenIndex { index } => Self::ForbiddenIndex { index }, @@ -1269,6 +1325,15 @@ impl std::fmt::Display for PubGrubHint { dependency.cyan(), ) } + Self::DependsOnItself { package } => { + write!( + f, + "{}{} The package `{}` depends on itself. This is likely a mistake. Consider removing the dependency.", + "hint".bold().cyan(), + ":".bold(), + package.cyan(), + ) + } Self::UncheckedIndex { package, range, diff --git a/crates/uv-resolver/src/resolver/mod.rs b/crates/uv-resolver/src/resolver/mod.rs index f8ee9441f0d3e..f16c04fbfdf6d 100644 --- a/crates/uv-resolver/src/resolver/mod.rs +++ b/crates/uv-resolver/src/resolver/mod.rs @@ -2453,8 +2453,24 @@ impl ForkState { extra: ref dependency_extra, dev: ref dependency_dev, marker: ref dependency_marker, - .. } => { + debug_assert!( + dependency_extra.is_none(), + "Packages should depend on an extra proxy" + ); + debug_assert!( + dependency_dev.is_none(), + "Packages should depend on a group proxy" + ); + + // Ignore self-dependencies (e.g., `tensorflow-macos` depends on `tensorflow-macos`), + // but allow groups to depend on other groups, or on the package itself. + if self_dev.is_none() { + if self_name == Some(dependency_name) { + continue; + } + } + let to_url = self.fork_urls.get(dependency_name); let to_index = self.fork_indexes.get(dependency_name); let edge = ResolutionDependencyEdge { @@ -2478,8 +2494,15 @@ impl ForkState { PubGrubPackageInner::Marker { name: ref dependency_name, marker: ref dependency_marker, - .. } => { + // Ignore self-dependencies (e.g., `tensorflow-macos` depends on `tensorflow-macos`), + // but allow groups to depend on other groups, or on the package itself. + if self_dev.is_none() { + if self_name == Some(dependency_name) { + continue; + } + } + let to_url = self.fork_urls.get(dependency_name); let to_index = self.fork_indexes.get(dependency_name); let edge = ResolutionDependencyEdge { @@ -2504,8 +2527,14 @@ impl ForkState { name: ref dependency_name, extra: ref dependency_extra, marker: ref dependency_marker, - .. } => { + if self_dev.is_none() { + debug_assert!( + self_name != Some(dependency_name), + "Extras should be flattened" + ); + } + // Insert an edge from the dependent package to the extra package. let to_url = self.fork_urls.get(dependency_name); let to_index = self.fork_indexes.get(dependency_name); @@ -2551,8 +2580,12 @@ impl ForkState { name: ref dependency_name, dev: ref dependency_dev, marker: ref dependency_marker, - .. } => { + debug_assert!( + self_name != Some(dependency_name), + "Groups should be flattened" + ); + // Add an edge from the dependent package to the dev package, but _not_ the // base package. let to_url = self.fork_urls.get(dependency_name); diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 2c266c995bd92..7c6658b2b1bc3 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -19234,3 +19234,667 @@ fn no_lowest_warning_with_name_and_url() -> Result<()> { Ok(()) } + +#[test] +fn lock_self_compatible() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions", "project"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "project" }, + { name = "typing-extensions" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }, + ] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Re-run with `--offline`. We shouldn't need a network connection to validate an + // already-correct lockfile with immutable metadata. + uv_snapshot!(context.filters(), context.lock().arg("--locked").arg("--offline").arg("--no-cache"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + typing-extensions==4.10.0 + "###); + + Ok(()) +} + +#[test] +fn lock_self_exact() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions", "project==0.1.0"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "project", specifier = "==0.1.0" }, + { name = "typing-extensions" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }, + ] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Re-run with `--offline`. We shouldn't need a network connection to validate an + // already-correct lockfile with immutable metadata. + uv_snapshot!(context.filters(), context.lock().arg("--locked").arg("--offline").arg("--no-cache"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + typing-extensions==4.10.0 + "###); + + Ok(()) +} + +#[test] +fn lock_self_incompatible() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions", "project==0.2.0"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because your project depends on itself at an incompatible version (project==0.2.0), we can conclude that your project's requirements are unsatisfiable. + + hint: The package `project` depends on itself. This is likely a mistake. Consider removing the dependency. + "###); + + Ok(()) +} + +#[test] +fn lock_self_extra_to_extra_compatible() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [project.optional-dependencies] + foo = ["project[foo]"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "project", extras = ["foo"], marker = "extra == 'foo'" }, + { name = "typing-extensions" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }, + ] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Re-run with `--offline`. We shouldn't need a network connection to validate an + // already-correct lockfile with immutable metadata. + uv_snapshot!(context.filters(), context.lock().arg("--locked").arg("--offline").arg("--no-cache"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + typing-extensions==4.10.0 + "###); + + Ok(()) +} + +#[test] +fn lock_self_extra_to_extra_incompatible() -> Result<()> { + let context = TestContext::new("3.12"); + + // TODO(charlie): This should fail, but currently succeeds. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [project.optional-dependencies] + foo = ["project[foo]==0.2.0"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "project", extras = ["foo"], marker = "extra == 'foo'", specifier = "==0.2.0" }, + { name = "typing-extensions" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }, + ] + "### + ); + }); + + Ok(()) +} + +#[test] +fn lock_self_extra_compatible() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [project.optional-dependencies] + foo = ["project"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "project", marker = "extra == 'foo'" }, + { name = "typing-extensions" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }, + ] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Re-run with `--offline`. We shouldn't need a network connection to validate an + // already-correct lockfile with immutable metadata. + uv_snapshot!(context.filters(), context.lock().arg("--locked").arg("--offline").arg("--no-cache"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + typing-extensions==4.10.0 + "###); + + Ok(()) +} + +#[test] +fn lock_self_extra_incompatible() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [project.optional-dependencies] + foo = ["project==0.2.0"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because project[foo] depends on your project and your project requires project[foo], we can conclude that your project's requirements are unsatisfiable. + + hint: The package `project[foo]` depends on itself. This is likely a mistake. Consider removing the dependency. + "###); + + Ok(()) +} + +#[test] +fn lock_self_marker_compatible() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions", "project ; sys_platform == 'win32'"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [ + { name = "project", marker = "sys_platform == 'win32'" }, + { name = "typing-extensions" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926 }, + ] + "### + ); + }); + + // Re-run with `--locked`. + uv_snapshot!(context.filters(), context.lock().arg("--locked"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Re-run with `--offline`. We shouldn't need a network connection to validate an + // already-correct lockfile with immutable metadata. + uv_snapshot!(context.filters(), context.lock().arg("--locked").arg("--offline").arg("--no-cache"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync().arg("--frozen"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + typing-extensions==4.10.0 + "###); + + Ok(()) +} + +#[test] +fn lock_self_marker_incompatible() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions", "project>0.1 ; sys_platform == 'win32'"] + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because only project{sys_platform == 'win32'}<=0.1 is available and your project depends on project{sys_platform == 'win32'}>0.1, we can conclude that your project's requirements are unsatisfiable. + + hint: The package `project` depends on itself. This is likely a mistake. Consider removing the dependency. + "###); + + Ok(()) +}