Use custom incompatibility

This commit is contained in:
konstin 2025-08-26 12:20:29 +02:00
parent d962db6fba
commit 24ed6d02f0
5 changed files with 143 additions and 18 deletions

View File

@ -1149,14 +1149,6 @@ impl SimpleMetadata {
warn!("Skipping file for {package_name}: {}", file.filename);
continue;
};
if filename.name() != package_name {
warn!(
"Skipping file with mismatched package name: `{}` vs. `{}`",
filename.name(),
package_name
);
continue;
}
let file = match File::try_from(file, &base) {
Ok(file) => file,
Err(err) => {

View File

@ -2,6 +2,7 @@ use std::fmt::{Display, Formatter};
use crate::resolver::{MetadataUnavailable, VersionFork};
use uv_distribution_types::IncompatibleDist;
use uv_normalize::PackageName;
use uv_pep440::{Version, VersionSpecifiers};
use uv_platform_tags::{AbiTag, Tags};
@ -38,6 +39,8 @@ pub enum UnavailableVersion {
InconsistentMetadata,
/// The wheel has an invalid structure.
InvalidStructure,
/// A package with the wrong name is on an index page.
PackageNameMismatch { dist_name: PackageName },
/// The wheel metadata was not found in the cache and the network is not available.
Offline,
/// The source distribution has a `requires-python` requirement that is not met by the installed
@ -52,6 +55,11 @@ impl UnavailableVersion {
Self::InvalidMetadata => "invalid metadata".into(),
Self::InconsistentMetadata => "inconsistent metadata".into(),
Self::InvalidStructure => "an invalid package format".into(),
Self::PackageNameMismatch {
dist_name: wrong_name,
} => {
format!("the wrong package name in the index page (`{wrong_name}`)")
}
Self::Offline => "to be downloaded from a registry".into(),
Self::RequiresPython(requires_python) => {
format!("Python {requires_python}")
@ -65,6 +73,7 @@ impl UnavailableVersion {
Self::InvalidMetadata => format!("has {self}"),
Self::InconsistentMetadata => format!("has {self}"),
Self::InvalidStructure => format!("has {self}"),
Self::PackageNameMismatch { .. } => format!("has {self}"),
Self::Offline => format!("needs {self}"),
Self::RequiresPython(..) => format!("requires {self}"),
}
@ -76,6 +85,7 @@ impl UnavailableVersion {
Self::InvalidMetadata => format!("have {self}"),
Self::InconsistentMetadata => format!("have {self}"),
Self::InvalidStructure => format!("have {self}"),
Self::PackageNameMismatch { .. } => format!("have {self}"),
Self::Offline => format!("need {self}"),
Self::RequiresPython(..) => format!("require {self}"),
}
@ -93,6 +103,7 @@ impl UnavailableVersion {
Self::InvalidMetadata => None,
Self::InconsistentMetadata => None,
Self::InvalidStructure => None,
Self::PackageNameMismatch { .. } => None,
Self::Offline => None,
Self::RequiresPython(..) => None,
}

View File

@ -1370,7 +1370,11 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
candidate.choice_kind(),
filename,
);
self.visit_candidate(&candidate, dist, package, name, pins, request_sink)?;
if let Some(incompatibility) =
self.visit_candidate(&candidate, dist, package, name, pins, request_sink)?
{
return Ok(Some(incompatibility));
}
let version = candidate.version().clone();
Ok(Some(ResolverVersion::Unforked(version)))
@ -1531,14 +1535,17 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
base_candidate.choice_kind(),
filename,
);
self.visit_candidate(
if let Some(incompatibility) = self.visit_candidate(
&base_candidate,
base_dist,
package,
name,
pins,
request_sink,
)?;
)? {
return Ok(Some(incompatibility));
}
return Ok(Some(ResolverVersion::Unforked(
base_candidate.version().clone(),
@ -1585,15 +1592,21 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
.collect::<Vec<_>>()
.join(", ")
);
self.visit_candidate(candidate, dist, package, name, pins, request_sink)?;
self.visit_candidate(
if let Some(incompatibility) =
self.visit_candidate(candidate, dist, package, name, pins, request_sink)?
{
return Ok(Some(incompatibility));
}
if let Some(incompatibility) = self.visit_candidate(
&base_candidate,
base_dist,
package,
name,
pins,
request_sink,
)?;
)? {
return Ok(Some(incompatibility));
}
let forks = vec![
VersionFork {
@ -1611,6 +1624,8 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
}
/// Visit a selected candidate.
///
/// Returns an unavailability if the version can't be used.
fn visit_candidate(
&self,
candidate: &Candidate,
@ -1619,7 +1634,18 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
name: &PackageName,
pins: &mut FilePins,
request_sink: &Sender<Request>,
) -> Result<(), ResolveError> {
) -> Result<Option<ResolverVersion>, ResolveError> {
// If there is a package with a different name then the index page on the index, return
// an unavailability.
if dist.name() != (name) {
return Ok(Some(ResolverVersion::Unavailable(
candidate.version().clone(),
UnavailableVersion::PackageNameMismatch {
dist_name: dist.name().clone(),
},
)));
}
// We want to return a package pinned to a specific version; but we _also_ want to
// store the exact file that we selected to satisfy that version.
pins.insert(candidate, dist);
@ -1649,7 +1675,7 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
}
}
Ok(())
Ok(None)
}
/// Check if the distribution is incompatible with the Python requirement, and if so, return
@ -2500,6 +2526,18 @@ impl<InstalledPackages: InstalledPackagesProvider> ResolverState<InstalledPackag
return Ok(None);
}
// If there is a package with a different name then the index page on the index,
// `chose_version` later returns an unavailability. Ignoring it here allows the
// resolver to continue instead of erroring from a prefetch.
if dist.name() != &package_name {
warn!(
"Expected packages name {} and distribution name {} do not match, skipping prefetch",
dist.name(),
package_name
);
return Ok(None);
}
// Emit a request to fetch the metadata for this version.
if self.index.distributions().register(candidate.version_id()) {
let dist = dist.for_resolution().to_owned();

View File

@ -17756,3 +17756,84 @@ fn omit_python_patch_universal() -> Result<()> {
Ok(())
}
/// Test for cases where the index page for a package contains distributions for a different
/// package.
#[tokio::test]
async fn package_name_on_index_package_mismatch() -> Result<()> {
let context = TestContext::new("3.12");
let server = MockServer::start().await;
// An index for anyio, with tqdm and anyio distributions.
let networkx_page = r#"
<!DOCTYPE html>
<html>
<body>
<h1>Links for anyio</h1>
<a href="https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl#sha256=26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2" data-requires-python="&gt;=3.7" data-dist-info-metadata="sha256=688a1632df525a198fec52dcfefb1246d31eacee9c035aacac2d7eaf8d8ff669" data-core-metadata="sha256=688a1632df525a198fec52dcfefb1246d31eacee9c035aacac2d7eaf8d8ff669">tqdm-4.67.1-py3-none-any.whl</a><br />
<a href="https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz#sha256=f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2" data-requires-python="&gt;=3.7" >tqdm-4.67.1.tar.gz</a><br />
<a href="https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl#sha256=60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1" data-requires-python="&gt;=3.9" data-dist-info-metadata="sha256=d400ffeb480f82ac562ac3b9e055136ca0d01f29e2e63ff9ebf5d08a7289f4b4" data-core-metadata="sha256=d400ffeb480f82ac562ac3b9e055136ca0d01f29e2e63ff9ebf5d08a7289f4b4">anyio-4.10.0-py3-none-any.whl</a><br />
<a href="https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz#sha256=3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6" data-requires-python="&gt;=3.9" >anyio-4.10.0.tar.gz</a><br />
</body>
</html>
"#;
Mock::given(method("GET"))
.and(path("/anyio/"))
.respond_with(ResponseTemplate::new(200).set_body_raw(networkx_page, "text/html"))
.mount(&server)
.await;
// Skip over the tqdm version, resolve to the older anyio version.
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str("anyio")?;
uv_snapshot!(context
.pip_compile()
.env_remove(EnvVars::UV_EXCLUDE_NEWER)
.arg("--extra-index-url")
.arg(server.uri())
.arg("requirements.in"), @r"
success: true
exit_code: 0
----- stdout -----
# This file was autogenerated by uv via the following command:
# uv pip compile --cache-dir [CACHE_DIR] requirements.in
anyio==4.10.0
# via -r requirements.in
idna==3.10
# via anyio
sniffio==1.3.1
# via anyio
typing-extensions==4.15.0
# via anyio
----- stderr -----
Resolved 4 packages in [TIME]
");
// Error with our custom incompatibility when there are only bogus packages in the version
// range.
let requirements_in = context.temp_dir.child("requirements.in");
requirements_in.write_str("anyio>4.10")?;
uv_snapshot!(context
.pip_compile()
.env_remove(EnvVars::UV_EXCLUDE_NEWER)
.arg("--extra-index-url")
.arg(server.uri())
.arg("requirements.in"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies:
Because only the following versions of anyio are available:
anyio<4.10
anyio==4.67.1
and anyio==4.67.1 has the wrong package name in the index page (`tqdm`), we can conclude that anyio>4.10 cannot be used.
And because you require anyio>4.10, we can conclude that your requirements are unsatisfiable.
");
Ok(())
}

View File

@ -11688,11 +11688,14 @@ async fn bogus_redirect() -> Result<()> {
.arg("sniffio"),
@r"
success: false
exit_code: 2
exit_code: 1
----- stdout -----
----- stderr -----
error: The index returned metadata for the wrong package: expected distribution for sniffio, got distribution for anyio
× No solution found when resolving dependencies:
Because all versions of sniffio have the wrong package name in the index page (`anyio`) and you require sniffio, we can conclude that your requirements are unsatisfiable.
hint: Pre-releases are available for `sniffio` in the requested range (e.g., 4.0.0rc1), but pre-releases weren't enabled (try: `--prerelease=allow`)
"
);