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
This commit is contained in:
Zanie Blue 2024-02-05 08:43:05 -06:00 committed by GitHub
parent be9125b0f0
commit d090acf13d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 351 additions and 74 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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" }

View file

@ -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()
}

View file

@ -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<PackageName>,
package_versions: &OnceMap<PackageName, VersionMap>,
package_versions: &OnceMap<PackageName, VersionsResponse>,
) -> Self {
let mut available_versions = IndexMap::default();
for package in self.derivation_tree.packages() {
@ -192,7 +192,8 @@ 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) {
if let Some(response) = package_versions.get(name) {
if let VersionsResponse::Found(ref version_map) = *response {
available_versions.insert(
package.clone(),
version_map
@ -205,6 +206,7 @@ impl NoSolutionError {
}
}
}
}
self.available_versions = available_versions;
self
}

View file

@ -35,7 +35,7 @@ impl ReportFormatter<PubGrubPackage, Range<Version>> 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<PubGrubPackage, Range<Version>> 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) {

View file

@ -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<PubGrubPackage, Version>,
pins: &FilePins,
packages: &OnceMap<PackageName, VersionMap>,
packages: &OnceMap<PackageName, VersionsResponse>,
distributions: &OnceMap<PackageId, Metadata21>,
redirects: &DashMap<Url, Url>,
state: &State<PubGrubPackage, Range<Version>, PubGrubPriority>,
@ -68,13 +70,15 @@ impl ResolutionGraph {
.clone();
// Add its hashes to the index.
if let Some(version_map) = packages.get(package_name) {
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.
let index = petgraph.add_node(pinned_package);
@ -93,13 +97,15 @@ impl ResolutionGraph {
};
// Add its hashes to the index.
if let Some(version_map) = packages.get(package_name) {
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.
let index = petgraph.add_node(pinned_package);

View file

@ -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<PackageName, VersionMap>,
pub(crate) packages: OnceMap<PackageName, VersionsResponse>,
/// A map from package ID to metadata for that distribution.
pub(crate) distributions: OnceMap<PackageId, Metadata21>,

View file

@ -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<PackageName>,
requirements: Vec<Requirement>,
@ -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<PackageId, VersionSpecifiers>,
/// Incompatibilities for specific package versions
unavailable_versions: DashMap<PackageId, UnavailableVersion>,
/// Incompatibilities for packages that are entirely unavailable
unavailable_packages: DashMap<PackageName, UnavailablePackage>,
/// The set of all registry-based packages visited during resolution.
visited: DashSet<PackageName>,
editables: FxHashMap<PackageName, (LocalEditable, Metadata21)>,
@ -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,

View file

@ -18,15 +18,26 @@ use crate::python_requirement::PythonRequirement;
use crate::version_map::VersionMap;
use crate::yanks::AllowedYanks;
type VersionMapResponse = Result<VersionMap, puffin_client::Error>;
type WheelMetadataResponse = Result<(Metadata21, Option<Url>), puffin_distribution::Error>;
type PackageVersionsResult = Result<VersionsResponse, puffin_client::Error>;
type WheelMetadataResult = Result<(Metadata21, Option<Url>), 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<Output = VersionMapResponse> + Send + 'io;
) -> impl Future<Output = PackageVersionsResult> + 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<Output = WheelMetadataResponse> + Send + 'io;
) -> impl Future<Output = WheelMetadataResult> + 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
}

View file

@ -2987,11 +2987,13 @@ fn no_index_requirements_txt() -> Result<()> {
puffin_snapshot!(context.compile()
.arg("requirements.in"), @r###"
success: false
exit_code: 2
exit_code: 1
----- stdout -----
----- stderr -----
error: tqdm isn't available locally, but making network requests to registries was banned.
× 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.
"###
);

View file

@ -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() {

View file

@ -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);

View file

@ -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");