From d090acf13d46f44ae43ebe4f85dad50ab70b8647 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 5 Feb 2024 08:43:05 -0600 Subject: [PATCH] Improve error messaging when a dependency is not found (#1241) Previously, whenever we encountered a missing package we would throw an error without information about why the package was requested. This meant that if a transitive dependency required a missing package, the user would have no idea why it was even selected. Here, we track `NotFound` and `NoIndex` errors as `NoVersions` incompatibilities with an attached reason. Improves our test coverage for `--no-index` without `--find-links`. The [snapshots](https://github.com/astral-sh/puffin/pull/1241/files#diff-3eea1658f165476252f1f061d0aa9f915aabdceafac21611cdf45019447f60ec) show a nice improvement. I think this will also enable backtracking to another version if some version of transitive dependency has a missing dependency. I'll write a scenario for that next. Requires https://github.com/zanieb/pubgrub/pull/22 --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/puffin-client/src/error.rs | 4 + crates/puffin-resolver/src/error.rs | 22 +-- crates/puffin-resolver/src/pubgrub/report.rs | 15 +- crates/puffin-resolver/src/resolution.rs | 34 +++-- crates/puffin-resolver/src/resolver/index.rs | 4 +- crates/puffin-resolver/src/resolver/mod.rs | 136 +++++++++++++++--- .../puffin-resolver/src/resolver/provider.rs | 46 ++++-- crates/puffin/tests/pip_compile.rs | 14 +- crates/puffin/tests/pip_install.rs | 45 ++++++ crates/puffin/tests/pip_install_scenarios.rs | 11 +- crates/puffin/tests/pip_sync.rs | 90 ++++++++++++ 13 files changed, 351 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e4d62a7d..00452ae51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2442,7 +2442,7 @@ dependencies = [ [[package]] name = "pubgrub" version = "0.2.1" -source = "git+https://github.com/zanieb/pubgrub?rev=86447f2a391c0aa25c56acb3a8b6ce10aac305b6#86447f2a391c0aa25c56acb3a8b6ce10aac305b6" +source = "git+https://github.com/zanieb/pubgrub?rev=1b150cdbd1e6f93b1f465de9d08f499660d7f708#1b150cdbd1e6f93b1f465de9d08f499660d7f708" dependencies = [ "indexmap 2.2.2", "log", diff --git a/Cargo.toml b/Cargo.toml index aadafe932..33f4b8b02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ owo-colors = { version = "4.0.0" } petgraph = { version = "0.6.4" } platform-info = { version = "2.0.2" } plist = { version = "1.6.0" } -pubgrub = { git = "https://github.com/zanieb/pubgrub", rev = "86447f2a391c0aa25c56acb3a8b6ce10aac305b6" } +pubgrub = { git = "https://github.com/zanieb/pubgrub", rev = "1b150cdbd1e6f93b1f465de9d08f499660d7f708" } pyo3 = { version = "0.20.2" } pyo3-log = { version = "0.9.0"} pyproject-toml = { version = "0.8.1" } diff --git a/crates/puffin-client/src/error.rs b/crates/puffin-client/src/error.rs index d519fd91c..13ec0113e 100644 --- a/crates/puffin-client/src/error.rs +++ b/crates/puffin-client/src/error.rs @@ -18,6 +18,10 @@ impl Error { *self.kind } + pub fn kind(&self) -> &ErrorKind { + &self.kind + } + pub(crate) fn from_json_err(err: serde_json::Error, url: Url) -> Self { ErrorKind::BadJson { source: err, url }.into() } diff --git a/crates/puffin-resolver/src/error.rs b/crates/puffin-resolver/src/error.rs index 6e44f0315..0381c0b95 100644 --- a/crates/puffin-resolver/src/error.rs +++ b/crates/puffin-resolver/src/error.rs @@ -17,7 +17,7 @@ use puffin_normalize::PackageName; use crate::candidate_selector::CandidateSelector; use crate::pubgrub::{PubGrubPackage, PubGrubPython, PubGrubReportFormatter}; use crate::python_requirement::PythonRequirement; -use crate::version_map::VersionMap; +use crate::resolver::VersionsResponse; #[derive(Debug, thiserror::Error)] pub enum ResolveError { @@ -168,7 +168,7 @@ impl NoSolutionError { mut self, python_requirement: &PythonRequirement, visited: &DashSet, - package_versions: &OnceMap, + package_versions: &OnceMap, ) -> Self { let mut available_versions = IndexMap::default(); for package in self.derivation_tree.packages() { @@ -192,14 +192,16 @@ impl NoSolutionError { // these packages, but it's non-deterministic, and omitting them ensures that // we represent the state of the resolver at the time of failure. if visited.contains(name) { - if let Some(version_map) = package_versions.get(name) { - available_versions.insert( - package.clone(), - version_map - .iter() - .map(|(version, _)| version.clone()) - .collect(), - ); + if let Some(response) = package_versions.get(name) { + if let VersionsResponse::Found(ref version_map) = *response { + available_versions.insert( + package.clone(), + version_map + .iter() + .map(|(version, _)| version.clone()) + .collect(), + ); + } } } } diff --git a/crates/puffin-resolver/src/pubgrub/report.rs b/crates/puffin-resolver/src/pubgrub/report.rs index 99d1ef9c5..6733c4e38 100644 --- a/crates/puffin-resolver/src/pubgrub/report.rs +++ b/crates/puffin-resolver/src/pubgrub/report.rs @@ -35,7 +35,7 @@ impl ReportFormatter> for PubGrubReportFormatter< External::NotRoot(package, version) => { format!("we are solving dependencies of {package} {version}") } - External::NoVersions(package, set) => { + External::NoVersions(package, set, reason) => { if matches!(package, PubGrubPackage::Python(_)) { if let Some(python) = self.python_requirement { if python.target() == python.installed() { @@ -75,6 +75,17 @@ impl ReportFormatter> for PubGrubReportFormatter< ); } let set = self.simplify_set(set, package); + + // Check for a reason + if let Some(reason) = reason { + let formatted = if set.as_ref() == &Range::full() { + format!("{package} {reason}") + } else { + format!("{package}{set} {reason}") + }; + return formatted; + } + if set.as_ref() == &Range::full() { format!("there are no versions of {package}") } else if set.as_singleton().is_some() { @@ -353,7 +364,7 @@ impl PubGrubReportFormatter<'_> { let mut hints = IndexSet::default(); match derivation_tree { DerivationTree::External(external) => match external { - External::NoVersions(package, set) => { + External::NoVersions(package, set, _) => { if set.bounds().any(Version::any_prerelease) { // A pre-release marker appeared in the version requirements. if !allowed_prerelease(package, selector) { diff --git a/crates/puffin-resolver/src/resolution.rs b/crates/puffin-resolver/src/resolution.rs index be2575f3f..67e2befc2 100644 --- a/crates/puffin-resolver/src/resolution.rs +++ b/crates/puffin-resolver/src/resolution.rs @@ -8,6 +8,7 @@ use petgraph::Direction; use pubgrub::range::Range; use pubgrub::solver::{Kind, State}; use pubgrub::type_aliases::SelectedDependencies; + use rustc_hash::FxHashMap; use url::Url; @@ -20,7 +21,8 @@ use pypi_types::{Hashes, Metadata21}; use crate::pins::FilePins; use crate::pubgrub::{PubGrubDistribution, PubGrubPackage, PubGrubPriority}; -use crate::version_map::VersionMap; +use crate::resolver::VersionsResponse; + use crate::ResolveError; /// A complete resolution graph in which every node represents a pinned package and every edge @@ -42,7 +44,7 @@ impl ResolutionGraph { pub(crate) fn from_state( selection: &SelectedDependencies, pins: &FilePins, - packages: &OnceMap, + packages: &OnceMap, distributions: &OnceMap, redirects: &DashMap, state: &State, PubGrubPriority>, @@ -68,12 +70,14 @@ impl ResolutionGraph { .clone(); // Add its hashes to the index. - if let Some(version_map) = packages.get(package_name) { - hashes.insert(package_name.clone(), { - let mut hashes = version_map.hashes(version); - hashes.sort_unstable(); - hashes - }); + if let Some(versions_response) = packages.get(package_name) { + if let VersionsResponse::Found(ref version_map) = *versions_response { + hashes.insert(package_name.clone(), { + let mut hashes = version_map.hashes(version); + hashes.sort_unstable(); + hashes + }); + } } // Add the distribution to the graph. @@ -93,12 +97,14 @@ impl ResolutionGraph { }; // Add its hashes to the index. - if let Some(version_map) = packages.get(package_name) { - hashes.insert(package_name.clone(), { - let mut hashes = version_map.hashes(version); - hashes.sort_unstable(); - hashes - }); + if let Some(versions_response) = packages.get(package_name) { + if let VersionsResponse::Found(ref version_map) = *versions_response { + hashes.insert(package_name.clone(), { + let mut hashes = version_map.hashes(version); + hashes.sort_unstable(); + hashes + }); + } } // Add the distribution to the graph. diff --git a/crates/puffin-resolver/src/resolver/index.rs b/crates/puffin-resolver/src/resolver/index.rs index 7b8d0bb5e..aaab59651 100644 --- a/crates/puffin-resolver/src/resolver/index.rs +++ b/crates/puffin-resolver/src/resolver/index.rs @@ -6,14 +6,14 @@ use once_map::OnceMap; use puffin_normalize::PackageName; use pypi_types::Metadata21; -use crate::version_map::VersionMap; +use super::provider::VersionsResponse; /// In-memory index of package metadata. #[derive(Default)] pub struct InMemoryIndex { /// A map from package name to the metadata for that package and the index where the metadata /// came from. - pub(crate) packages: OnceMap, + pub(crate) packages: OnceMap, /// A map from package ID to metadata for that distribution. pub(crate) distributions: OnceMap, diff --git a/crates/puffin-resolver/src/resolver/mod.rs b/crates/puffin-resolver/src/resolver/mod.rs index f320bd2ac..d12206543 100644 --- a/crates/puffin-resolver/src/resolver/mod.rs +++ b/crates/puffin-resolver/src/resolver/mod.rs @@ -47,9 +47,9 @@ use crate::resolver::allowed_urls::AllowedUrls; pub use crate::resolver::index::InMemoryIndex; use crate::resolver::provider::DefaultResolverProvider; pub use crate::resolver::provider::ResolverProvider; +pub(crate) use crate::resolver::provider::VersionsResponse; use crate::resolver::reporter::Facade; pub use crate::resolver::reporter::{BuildId, Reporter}; -use crate::version_map::VersionMap; use crate::{DependencyMode, Options}; mod allowed_urls; @@ -57,6 +57,23 @@ mod index; mod provider; mod reporter; +/// The package version is unavailable and cannot be used +/// Unlike [`PackageUnavailable`] this applies to a single version of the package +#[derive(Debug, Clone)] +pub(crate) enum UnavailableVersion { + /// Version is incompatible due to the `Requires-Python` version specifiers for that package. + RequiresPython(VersionSpecifiers), +} + +/// The package is unavailable and cannot be used +#[derive(Debug, Clone)] +pub(crate) enum UnavailablePackage { + /// The `--no-index` flag was passed and the package is not available locally + NoIndex, + /// The package was not found in the registry + NotFound, +} + pub struct Resolver<'a, Provider: ResolverProvider> { project: Option, requirements: Vec, @@ -68,8 +85,10 @@ pub struct Resolver<'a, Provider: ResolverProvider> { python_requirement: PythonRequirement, selector: CandidateSelector, index: &'a InMemoryIndex, - /// A map from [`PackageId`] to the `Requires-Python` version specifiers for that package. - incompatibilities: DashMap, + /// Incompatibilities for specific package versions + unavailable_versions: DashMap, + /// Incompatibilities for packages that are entirely unavailable + unavailable_packages: DashMap, /// The set of all registry-based packages visited during resolution. visited: DashSet, editables: FxHashMap, @@ -170,7 +189,8 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { Self { index, - incompatibilities: DashMap::default(), + unavailable_versions: DashMap::default(), + unavailable_packages: DashMap::default(), visited: DashSet::default(), selector, allowed_urls, @@ -314,7 +334,30 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { .term_intersection_for_package(&next) .expect("a package was chosen but we don't have a term."); - let inc = Incompatibility::no_versions(next.clone(), term_intersection.clone()); + let reason = { + if let PubGrubPackage::Package(ref package_name, _, _) = next { + // Check if the decision was due to the package being unavailable + self.unavailable_packages + .get(package_name) + .map(|entry| match *entry { + UnavailablePackage::NoIndex => { + "was not found in the provided links" + } + UnavailablePackage::NotFound => { + "was not found in the package registry" + } + }) + } else { + None + } + }; + + let inc = Incompatibility::no_versions( + next.clone(), + term_intersection.clone(), + reason.map(ToString::to_string), + ); + state.add_incompatibility(inc); continue; } @@ -510,7 +553,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { PubGrubPackage::Package(package_name, extra, None) => { // Wait for the metadata to be available. - let version_map = self + let versions_response = self .index .packages .wait(package_name) @@ -519,6 +562,23 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { .ok_or(ResolveError::Unregistered)?; self.visited.insert(package_name.clone()); + let version_map = match *versions_response { + VersionsResponse::Found(ref version_map) => version_map, + // Short-circuit if we do not find any versions for the package + VersionsResponse::NoIndex => { + self.unavailable_packages + .insert(package_name.clone(), UnavailablePackage::NoIndex); + + return Ok(None); + } + VersionsResponse::NotFound => { + self.unavailable_packages + .insert(package_name.clone(), UnavailablePackage::NotFound); + + return Ok(None); + } + }; + if let Some(extra) = extra { debug!( "Searching for a compatible version of {package_name}[{extra}] ({range})", @@ -528,16 +588,17 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { } // Find a compatible version. - let Some(candidate) = self.selector.select(package_name, range, &version_map) - else { + let Some(candidate) = self.selector.select(package_name, range, version_map) else { // Short circuit: we couldn't find _any_ compatible versions for a package. return Ok(None); }; // If the version is incompatible, short-circuit. if let Some(requires_python) = candidate.validate(&self.python_requirement) { - self.incompatibilities - .insert(candidate.package_id(), requires_python.clone()); + self.unavailable_versions.insert( + candidate.package_id(), + UnavailableVersion::RequiresPython(requires_python.clone()), + ); return Ok(Some(candidate.version().clone())); } @@ -655,17 +716,29 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { return Ok(Dependencies::Available(DependencyConstraints::default())); } - // Wait for the metadata to be available. + // Determine the distribution to lookup let dist = match url { Some(url) => PubGrubDistribution::from_url(package_name, url), None => PubGrubDistribution::from_registry(package_name, version), }; let package_id = dist.package_id(); + // If the package does not exist in the registry, we cannot fetch its dependencies + if self.unavailable_packages.get(package_name).is_some() { + debug_assert!( + false, + "Dependencies were requested for a package that is not available" + ); + return Ok(Dependencies::Unavailable( + "The package is unavailable".to_string(), + )); + } + // If the package is known to be incompatible, return the Python version as an // incompatibility, and skip fetching the metadata. - if let Some(entry) = self.incompatibilities.get(&package_id) { - let requires_python = entry; + if let Some(entry) = self.unavailable_versions.get(&package_id) { + // TODO(zanieb): Handle additional variants here + let UnavailableVersion::RequiresPython(requires_python) = entry.value(); let version = requires_python .iter() .map(PubGrubSpecifier::try_from) @@ -682,6 +755,7 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { return Ok(Dependencies::Available(constraints)); } + // Wait for the metadata to be available. let metadata = self .index .distributions @@ -779,13 +853,14 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { match request { // Fetch package metadata from the registry. Request::Package(package_name) => { - let version_map = self + let package_versions = self .provider - .get_version_map(&package_name) + .get_package_versions(&package_name) .boxed() .await .map_err(ResolveError::Client)?; - Ok(Some(Response::Package(package_name, version_map))) + + Ok(Some(Response::Package(package_name, package_versions))) } // Fetch distribution metadata from the distribution database. @@ -817,24 +892,43 @@ impl<'a, Provider: ResolverProvider> Resolver<'a, Provider> { // Pre-fetch the package and distribution metadata. Request::Prefetch(package_name, range) => { // Wait for the package metadata to become available. - let version_map = self + let versions_response = self .index .packages .wait(&package_name) .await .ok_or(ResolveError::Unregistered)?; + let version_map = match *versions_response { + VersionsResponse::Found(ref version_map) => version_map, + // Short-circuit if we did not find any versions for the package + VersionsResponse::NoIndex => { + self.unavailable_packages + .insert(package_name.clone(), UnavailablePackage::NoIndex); + + return Ok(None); + } + VersionsResponse::NotFound => { + self.unavailable_packages + .insert(package_name.clone(), UnavailablePackage::NotFound); + + return Ok(None); + } + }; + // Try to find a compatible version. If there aren't any compatible versions, // short-circuit and return `None`. - let Some(candidate) = self.selector.select(&package_name, &range, &version_map) + let Some(candidate) = self.selector.select(&package_name, &range, version_map) else { return Ok(None); }; // If the version is incompatible, short-circuit. if let Some(requires_python) = candidate.validate(&self.python_requirement) { - self.incompatibilities - .insert(candidate.package_id(), requires_python.clone()); + self.unavailable_versions.insert( + candidate.package_id(), + UnavailableVersion::RequiresPython(requires_python.clone()), + ); return Ok(None); } @@ -928,7 +1022,7 @@ impl Display for Request { #[allow(clippy::large_enum_variant)] enum Response { /// The returned metadata for a package hosted on a registry. - Package(PackageName, VersionMap), + Package(PackageName, VersionsResponse), /// The returned metadata for a distribution. Dist { dist: Dist, diff --git a/crates/puffin-resolver/src/resolver/provider.rs b/crates/puffin-resolver/src/resolver/provider.rs index 297671869..217ae5471 100644 --- a/crates/puffin-resolver/src/resolver/provider.rs +++ b/crates/puffin-resolver/src/resolver/provider.rs @@ -18,15 +18,26 @@ use crate::python_requirement::PythonRequirement; use crate::version_map::VersionMap; use crate::yanks::AllowedYanks; -type VersionMapResponse = Result; -type WheelMetadataResponse = Result<(Metadata21, Option), puffin_distribution::Error>; +type PackageVersionsResult = Result; +type WheelMetadataResult = Result<(Metadata21, Option), puffin_distribution::Error>; + +/// The response when requesting versions for a package +#[derive(Debug)] +pub enum VersionsResponse { + /// The package was found in the registry with the included versions + Found(VersionMap), + /// The package was not found in the registry + NotFound, + /// The package was not found in the local registry + NoIndex, +} pub trait ResolverProvider: Send + Sync { /// Get the version map for a package. - fn get_version_map<'io>( + fn get_package_versions<'io>( &'io self, package_name: &'io PackageName, - ) -> impl Future + Send + 'io; + ) -> impl Future + Send + 'io; /// Get the metadata for a distribution. /// @@ -36,7 +47,7 @@ pub trait ResolverProvider: Send + Sync { fn get_or_build_wheel_metadata<'io>( &'io self, dist: &'io Dist, - ) -> impl Future + Send + 'io; + ) -> impl Future + Send + 'io; /// Set the [`puffin_distribution::Reporter`] to use for this installer. #[must_use] @@ -104,7 +115,10 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider for DefaultResolverProvider<'a, Context> { /// Make a simple api request for the package and convert the result to a [`VersionMap`]. - async fn get_version_map<'io>(&'io self, package_name: &'io PackageName) -> VersionMapResponse { + async fn get_package_versions<'io>( + &'io self, + package_name: &'io PackageName, + ) -> PackageVersionsResult { let result = self.client.simple(package_name).await; // If the simple api request was successful, perform on the slow conversion to `VersionMap` on the tokio @@ -114,7 +128,7 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider let self_send = self.inner.clone(); let package_name_owned = package_name.clone(); Ok(tokio::task::spawn_blocking(move || { - VersionMap::from_metadata( + VersionsResponse::Found(VersionMap::from_metadata( metadata, &package_name_owned, &index, @@ -124,18 +138,24 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider self_send.exclude_newer.as_ref(), self_send.flat_index.get(&package_name_owned).cloned(), &self_send.no_binary, - ) + )) }) .await .expect("Tokio executor failed, was there a panic?")) } Err(err) => match err.into_kind() { - kind @ (puffin_client::ErrorKind::PackageNotFound(_) - | puffin_client::ErrorKind::NoIndex(_)) => { + puffin_client::ErrorKind::PackageNotFound(_) => { if let Some(flat_index) = self.flat_index.get(package_name).cloned() { - Ok(VersionMap::from(flat_index)) + Ok(VersionsResponse::Found(VersionMap::from(flat_index))) } else { - Err(kind.into()) + Ok(VersionsResponse::NotFound) + } + } + puffin_client::ErrorKind::NoIndex(_) => { + if let Some(flat_index) = self.flat_index.get(package_name).cloned() { + Ok(VersionsResponse::Found(VersionMap::from(flat_index))) + } else { + Ok(VersionsResponse::NoIndex) } } kind => Err(kind.into()), @@ -143,7 +163,7 @@ impl<'a, Context: BuildContext + Send + Sync> ResolverProvider } } - async fn get_or_build_wheel_metadata<'io>(&'io self, dist: &'io Dist) -> WheelMetadataResponse { + async fn get_or_build_wheel_metadata<'io>(&'io self, dist: &'io Dist) -> WheelMetadataResult { self.fetcher.get_or_build_wheel_metadata(dist).await } diff --git a/crates/puffin/tests/pip_compile.rs b/crates/puffin/tests/pip_compile.rs index 12a6a8aef..1497ea6e4 100644 --- a/crates/puffin/tests/pip_compile.rs +++ b/crates/puffin/tests/pip_compile.rs @@ -2986,13 +2986,15 @@ fn no_index_requirements_txt() -> Result<()> { puffin_snapshot!(context.compile() .arg("requirements.in"), @r###" - success: false - exit_code: 2 - ----- stdout ----- + success: false + exit_code: 1 + ----- stdout ----- - ----- stderr ----- - error: tqdm isn't available locally, but making network requests to registries was banned. - "### + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because tqdm was not found in the provided links and you require tqdm, + we can conclude that the requirements are unsatisfiable. + "### ); Ok(()) diff --git a/crates/puffin/tests/pip_install.rs b/crates/puffin/tests/pip_install.rs index fb1a35f91..085cd87aa 100644 --- a/crates/puffin/tests/pip_install.rs +++ b/crates/puffin/tests/pip_install.rs @@ -594,6 +594,51 @@ fn reinstall_build_system() -> Result<()> { Ok(()) } +/// Install a package without using the remote index +#[test] +fn install_no_index() { + let context = TestContext::new("3.12"); + + puffin_snapshot!(command(&context) + .arg("Flask") + .arg("--no-index"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because flask was not found in the provided links and you require flask, + we can conclude that the requirements are unsatisfiable. + "### + ); + + context.assert_command("import flask").failure(); +} + +/// Install a package without using the remote index +/// Covers a case where the user requests a version which should be included in the error +#[test] +fn install_no_index_version() { + let context = TestContext::new("3.12"); + + puffin_snapshot!(command(&context) + .arg("Flask==3.0.0") + .arg("--no-index"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies: + ╰─▶ Because flask==3.0.0 was not found in the provided links and you require + flask==3.0.0, we can conclude that the requirements are unsatisfiable. + "### + ); + + context.assert_command("import flask").failure(); +} + /// Install a package without using pre-built wheels. #[test] fn install_no_binary() { diff --git a/crates/puffin/tests/pip_install_scenarios.rs b/crates/puffin/tests/pip_install_scenarios.rs index d350875fb..822ad7c62 100644 --- a/crates/puffin/tests/pip_install_scenarios.rs +++ b/crates/puffin/tests/pip_install_scenarios.rs @@ -79,11 +79,12 @@ fn requires_package_does_not_exist() { .arg("a-3cb60d4c") , @r###" success: false - exit_code: 2 + exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Package `a` was not found in the registry. + × No solution found when resolving dependencies: + ╰─▶ Because a was not found in the package registry and you require a, we can conclude that the requirements are unsatisfiable. "###); assert_not_installed(&context.venv, "a_3cb60d4c", &context.temp_dir); @@ -237,11 +238,13 @@ fn transitive_requires_package_does_not_exist() { .arg("a-22a72022") , @r###" success: false - exit_code: 2 + exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Package `b` was not found in the registry. + × No solution found when resolving dependencies: + ╰─▶ Because b was not found in the package registry and albatross==1.0.0 depends on b, we can conclude that albatross==1.0.0 cannot be used. + And because only albatross==1.0.0 is available and you require albatross, we can conclude that the requirements are unsatisfiable. "###); assert_not_installed(&context.venv, "a_22a72022", &context.temp_dir); diff --git a/crates/puffin/tests/pip_sync.rs b/crates/puffin/tests/pip_sync.rs index f0898a362..6f231b246 100644 --- a/crates/puffin/tests/pip_sync.rs +++ b/crates/puffin/tests/pip_sync.rs @@ -42,6 +42,19 @@ fn command(context: &TestContext) -> Command { command } +/// Create a `pip uninstall` command with options shared across scenarios. +fn uninstall_command(context: &TestContext) -> Command { + let mut command = Command::new(get_bin()); + command + .arg("pip") + .arg("uninstall") + .arg("--cache-dir") + .arg(context.cache_dir.path()) + .env("VIRTUAL_ENV", context.venv.as_os_str()) + .current_dir(&context.temp_dir); + command +} + #[test] fn missing_requirements_txt() { let context = TestContext::new("3.12"); @@ -802,6 +815,83 @@ fn install_no_binary() -> Result<()> { Ok(()) } +/// Attempt to install a package without using a remote index. +#[test] +fn install_no_index() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.touch()?; + requirements_txt.write_str("MarkupSafe==2.1.3")?; + + puffin_snapshot!(command(&context) + .arg("requirements.txt") + .arg("--no-index") + .arg("--strict"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: markupsafe isn't available locally, but making network requests to registries was banned. + "### + ); + + context.assert_command("import markupsafe").failure(); + + Ok(()) +} + +/// Attempt to install a package without using a remote index +/// after a previous successful installation. +#[test] +fn install_no_index_cached() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.touch()?; + requirements_txt.write_str("MarkupSafe==2.1.3")?; + + puffin_snapshot!(command(&context) + .arg("requirements.txt") + .arg("--strict"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Installed 1 package in [TIME] + + markupsafe==2.1.3 + "### + ); + + context.assert_command("import markupsafe").success(); + + uninstall_command(&context) + .arg("markupsafe") + .assert() + .success(); + + puffin_snapshot!(command(&context) + .arg("requirements.txt") + .arg("--no-index") + .arg("--strict"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: markupsafe isn't available locally, but making network requests to registries was banned. + "### + ); + + context.assert_command("import markupsafe").failure(); + + Ok(()) +} + #[test] fn warn_on_yanked_version() -> Result<()> { let context = TestContext::new("3.12");