uv/crates/uv-requirements/src/unnamed.rs

344 lines
13 KiB
Rust

use std::borrow::Cow;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use configparser::ini::Ini;
use futures::{TryStreamExt, stream::FuturesOrdered};
use serde::Deserialize;
use tracing::debug;
use url::Host;
use uv_distribution::{DistributionDatabase, Reporter};
use uv_distribution_filename::{DistExtension, SourceDistFilename, WheelFilename};
use uv_distribution_types::{
BuildableSource, DirectSourceUrl, DirectorySourceUrl, GitSourceUrl, PathSourceUrl,
RemoteSource, Requirement, SourceUrl, VersionId,
};
use uv_normalize::PackageName;
use uv_pep508::{UnnamedRequirement, VersionOrUrl};
use uv_pypi_types::Metadata10;
use uv_pypi_types::{ParsedUrl, VerbatimParsedUrl};
use uv_resolver::{InMemoryIndex, MetadataResponse};
use uv_types::{BuildContext, HashStrategy};
use crate::Error;
/// Like [`RequirementsSpecification`], but with concrete names for all requirements.
pub struct NamedRequirementsResolver<'a, Context: BuildContext> {
/// Whether to check hashes for distributions.
hasher: &'a HashStrategy,
/// The in-memory index for resolving dependencies.
index: &'a InMemoryIndex,
/// The database for fetching and building distributions.
database: DistributionDatabase<'a, Context>,
}
impl<'a, Context: BuildContext> NamedRequirementsResolver<'a, Context> {
/// Instantiate a new [`NamedRequirementsResolver`].
pub fn new(
hasher: &'a HashStrategy,
index: &'a InMemoryIndex,
database: DistributionDatabase<'a, Context>,
) -> Self {
Self {
hasher,
index,
database,
}
}
/// Set the [`Reporter`] to use for this resolver.
#[must_use]
pub fn with_reporter(self, reporter: Arc<dyn Reporter>) -> Self {
Self {
database: self.database.with_reporter(reporter),
..self
}
}
/// Resolve any unnamed requirements in the specification.
pub async fn resolve(
self,
requirements: impl Iterator<Item = UnnamedRequirement<VerbatimParsedUrl>>,
) -> Result<Vec<Requirement>, Error> {
let Self {
hasher,
index,
database,
} = self;
requirements
.map(async |requirement| {
Self::resolve_requirement(requirement, hasher, index, &database)
.await
.map(Requirement::from)
})
.collect::<FuturesOrdered<_>>()
.try_collect()
.await
}
/// Infer the package name for a given "unnamed" requirement.
async fn resolve_requirement(
requirement: UnnamedRequirement<VerbatimParsedUrl>,
hasher: &HashStrategy,
index: &InMemoryIndex,
database: &DistributionDatabase<'a, Context>,
) -> Result<uv_pep508::Requirement<VerbatimParsedUrl>, Error> {
// If the requirement is a wheel, extract the package name from the wheel filename.
//
// Ex) `anyio-4.3.0-py3-none-any.whl`
if Path::new(requirement.url.verbatim.path())
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("whl"))
{
let filename = WheelFilename::from_str(&requirement.url.verbatim.filename()?)?;
return Ok(uv_pep508::Requirement {
name: filename.name,
extras: requirement.extras,
version_or_url: Some(VersionOrUrl::Url(requirement.url)),
marker: requirement.marker,
origin: requirement.origin,
});
}
// If the requirement is a source archive, try to extract the package name from the archive
// filename. This isn't guaranteed to work.
//
// Ex) `anyio-4.3.0.tar.gz`
if let Some(filename) = requirement
.url
.verbatim
.filename()
.ok()
.and_then(|filename| SourceDistFilename::parsed_normalized_filename(&filename).ok())
{
// But ignore GitHub archives, like:
// https://github.com/python/mypy/archive/refs/heads/release-1.11.zip
//
// These have auto-generated filenames that will almost never match the package name.
if requirement.url.verbatim.host() == Some(Host::Domain("github.com"))
&& requirement
.url
.verbatim
.path_segments()
.is_some_and(|mut path_segments| {
path_segments.any(|segment| segment == "archive")
})
{
debug!(
"Rejecting inferred name from GitHub archive: {}",
requirement.url.verbatim
);
} else {
return Ok(uv_pep508::Requirement {
name: filename.name,
extras: requirement.extras,
version_or_url: Some(VersionOrUrl::Url(requirement.url)),
marker: requirement.marker,
origin: requirement.origin,
});
}
}
let source = match &requirement.url.parsed_url {
// If the path points to a directory, attempt to read the name from static metadata.
ParsedUrl::Directory(parsed_directory_url) => {
// Attempt to read a `PKG-INFO` from the directory.
if let Some(metadata) =
fs_err::read(parsed_directory_url.install_path.join("PKG-INFO"))
.ok()
.and_then(|contents| Metadata10::parse_pkg_info(&contents).ok())
{
debug!(
"Found PKG-INFO metadata for {path} ({name})",
path = parsed_directory_url.install_path.display(),
name = metadata.name
);
return Ok(uv_pep508::Requirement {
name: metadata.name,
extras: requirement.extras,
version_or_url: Some(VersionOrUrl::Url(requirement.url)),
marker: requirement.marker,
origin: requirement.origin,
});
}
// Attempt to read a `pyproject.toml` file.
let project_path = parsed_directory_url.install_path.join("pyproject.toml");
if let Some(pyproject) = fs_err::read_to_string(project_path)
.ok()
.and_then(|contents| toml::from_str::<PyProjectToml>(&contents).ok())
{
// Read PEP 621 metadata from the `pyproject.toml`.
if let Some(project) = pyproject.project {
debug!(
"Found PEP 621 metadata for {path} in `pyproject.toml` ({name})",
path = parsed_directory_url.install_path.display(),
name = project.name
);
return Ok(uv_pep508::Requirement {
name: project.name,
extras: requirement.extras,
version_or_url: Some(VersionOrUrl::Url(requirement.url)),
marker: requirement.marker,
origin: requirement.origin,
});
}
// Read Poetry-specific metadata from the `pyproject.toml`.
if let Some(tool) = pyproject.tool {
if let Some(poetry) = tool.poetry {
if let Some(name) = poetry.name {
debug!(
"Found Poetry metadata for {path} in `pyproject.toml` ({name})",
path = parsed_directory_url.install_path.display(),
name = name
);
return Ok(uv_pep508::Requirement {
name,
extras: requirement.extras,
version_or_url: Some(VersionOrUrl::Url(requirement.url)),
marker: requirement.marker,
origin: requirement.origin,
});
}
}
}
}
// Attempt to read a `setup.cfg` from the directory.
if let Some(setup_cfg) =
fs_err::read_to_string(parsed_directory_url.install_path.join("setup.cfg"))
.ok()
.and_then(|contents| {
let mut ini = Ini::new_cs();
ini.set_multiline(true);
ini.read(contents).ok()
})
{
if let Some(section) = setup_cfg.get("metadata") {
if let Some(Some(name)) = section.get("name") {
if let Ok(name) = PackageName::from_str(name) {
debug!(
"Found setuptools metadata for {path} in `setup.cfg` ({name})",
path = parsed_directory_url.install_path.display(),
name = name
);
return Ok(uv_pep508::Requirement {
name,
extras: requirement.extras,
version_or_url: Some(VersionOrUrl::Url(requirement.url)),
marker: requirement.marker,
origin: requirement.origin,
});
}
}
}
}
SourceUrl::Directory(DirectorySourceUrl {
url: &requirement.url.verbatim,
install_path: Cow::Borrowed(&parsed_directory_url.install_path),
editable: parsed_directory_url.editable,
})
}
ParsedUrl::Path(parsed_path_url) => {
let ext = match parsed_path_url.ext {
DistExtension::Source(ext) => ext,
DistExtension::Wheel => unreachable!(),
};
SourceUrl::Path(PathSourceUrl {
url: &requirement.url.verbatim,
path: Cow::Borrowed(&parsed_path_url.install_path),
ext,
})
}
ParsedUrl::Archive(parsed_archive_url) => {
let ext = match parsed_archive_url.ext {
DistExtension::Source(ext) => ext,
DistExtension::Wheel => unreachable!(),
};
SourceUrl::Direct(DirectSourceUrl {
url: &parsed_archive_url.url,
subdirectory: parsed_archive_url.subdirectory.as_deref(),
ext,
})
}
ParsedUrl::Git(parsed_git_url) => SourceUrl::Git(GitSourceUrl {
url: &requirement.url.verbatim,
git: &parsed_git_url.url,
subdirectory: parsed_git_url.subdirectory.as_deref(),
}),
};
// Fetch the metadata for the distribution.
let name = {
let id = VersionId::from_url(source.url());
if let Some(archive) = index
.distributions()
.get(&id)
.as_deref()
.and_then(|response| {
if let MetadataResponse::Found(archive) = response {
Some(archive)
} else {
None
}
})
{
// If the metadata is already in the index, return it.
archive.metadata.name.clone()
} else {
// Run the PEP 517 build process to extract metadata from the source distribution.
let hashes = hasher.get_url(source.url());
let source = BuildableSource::Url(source);
let archive = database.build_wheel_metadata(&source, hashes).await?;
let name = archive.metadata.name.clone();
// Insert the metadata into the index.
index
.distributions()
.done(id, Arc::new(MetadataResponse::Found(archive)));
name
}
};
Ok(uv_pep508::Requirement {
name,
extras: requirement.extras,
version_or_url: Some(VersionOrUrl::Url(requirement.url)),
marker: requirement.marker,
origin: requirement.origin,
})
}
}
/// A pyproject.toml as specified in PEP 517.
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct PyProjectToml {
project: Option<Project>,
tool: Option<Tool>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct Project {
name: PackageName,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct Tool {
poetry: Option<ToolPoetry>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct ToolPoetry {
name: Option<PackageName>,
}