mirror of https://github.com/astral-sh/uv
344 lines
13 KiB
Rust
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>,
|
|
}
|