From cf3093283100ef7ff916ce2b7e9cc0061adb2886 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 27 Mar 2024 18:17:09 -0400 Subject: [PATCH] Allow prereleases, locals, and URLs in non-editable path requirements (#2671) ## Summary This PR enables the resolver to "accept" URLs, prereleases, and local version specifiers for direct dependencies of path dependencies. As a result, `uv pip install .` and `uv pip install -e .` now behave identically, in that neither has a restriction on URL dependencies and the like. Closes https://github.com/astral-sh/uv/issues/2643. Closes https://github.com/astral-sh/uv/issues/1853. --- crates/distribution-types/src/lib.rs | 8 + crates/distribution-types/src/requirements.rs | 35 ++++ crates/pep508-rs/src/verbatim_url.rs | 5 + crates/uv-requirements/src/lib.rs | 2 + crates/uv-requirements/src/lookahead.rs | 104 +++++++++++ crates/uv-resolver/src/manifest.rs | 29 ++++ crates/uv-resolver/src/prerelease_mode.rs | 10 ++ crates/uv-resolver/src/resolution_mode.rs | 7 +- crates/uv-resolver/src/resolver/locals.rs | 39 ++--- crates/uv-resolver/src/resolver/urls.rs | 51 ++++-- crates/uv-resolver/src/yanks.rs | 36 ++-- crates/uv-resolver/tests/resolver.rs | 5 + crates/uv-types/src/lib.rs | 2 + crates/uv-types/src/requirements.rs | 35 ++++ crates/uv/src/commands/pip_compile.rs | 11 +- crates/uv/src/commands/pip_install.rs | 20 ++- crates/uv/tests/pip_compile.rs | 162 ++++++++++++++++-- 17 files changed, 484 insertions(+), 77 deletions(-) create mode 100644 crates/distribution-types/src/requirements.rs create mode 100644 crates/uv-requirements/src/lookahead.rs create mode 100644 crates/uv-types/src/requirements.rs diff --git a/crates/distribution-types/src/lib.rs b/crates/distribution-types/src/lib.rs index ec2d76cd900a..972b80ec4e6c 100644 --- a/crates/distribution-types/src/lib.rs +++ b/crates/distribution-types/src/lib.rs @@ -425,6 +425,14 @@ impl SourceDist { dist => dist, } } + + /// Returns the path to the source distribution, if if it's a local distribution. + pub fn as_path(&self) -> Option<&Path> { + match self { + Self::Path(dist) => Some(&dist.path), + _ => None, + } + } } impl Name for RegistryBuiltDist { diff --git a/crates/distribution-types/src/requirements.rs b/crates/distribution-types/src/requirements.rs new file mode 100644 index 000000000000..8b8c4b851260 --- /dev/null +++ b/crates/distribution-types/src/requirements.rs @@ -0,0 +1,35 @@ +use pep508_rs::Requirement; +use uv_normalize::ExtraName; + +/// A set of requirements as requested by a parent requirement. +/// +/// For example, given `flask[dotenv]`, the `RequestedRequirements` would include the `dotenv` +/// extra, along with all of the requirements that are included in the `flask` distribution +/// including their unevaluated markers. +#[derive(Debug, Clone)] +pub struct RequestedRequirements { + /// The set of extras included on the originating requirement. + extras: Vec, + /// The set of requirements that were requested by the originating requirement. + requirements: Vec, +} + +impl RequestedRequirements { + /// Instantiate a [`RequestedRequirements`] with the given `extras` and `requirements`. + pub fn new(extras: Vec, requirements: Vec) -> Self { + Self { + extras, + requirements, + } + } + + /// Return the extras that were included on the originating requirement. + pub fn extras(&self) -> &[ExtraName] { + &self.extras + } + + /// Return the requirements that were included on the originating requirement. + pub fn requirements(&self) -> &[Requirement] { + &self.requirements + } +} diff --git a/crates/pep508-rs/src/verbatim_url.rs b/crates/pep508-rs/src/verbatim_url.rs index 218fec7e49ed..78c76ee318ef 100644 --- a/crates/pep508-rs/src/verbatim_url.rs +++ b/crates/pep508-rs/src/verbatim_url.rs @@ -385,6 +385,11 @@ impl Scheme { _ => None, } } + + /// Returns `true` if the scheme is a file scheme. + pub fn is_file(self) -> bool { + matches!(self, Self::File) + } } impl std::fmt::Display for Scheme { diff --git a/crates/uv-requirements/src/lib.rs b/crates/uv-requirements/src/lib.rs index ce7c95566ed7..8b0801d56861 100644 --- a/crates/uv-requirements/src/lib.rs +++ b/crates/uv-requirements/src/lib.rs @@ -1,9 +1,11 @@ +pub use crate::lookahead::*; pub use crate::resolver::*; pub use crate::source_tree::*; pub use crate::sources::*; pub use crate::specification::*; mod confirm; +mod lookahead; mod pyproject; mod resolver; mod source_tree; diff --git a/crates/uv-requirements/src/lookahead.rs b/crates/uv-requirements/src/lookahead.rs new file mode 100644 index 000000000000..835052804759 --- /dev/null +++ b/crates/uv-requirements/src/lookahead.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures::{StreamExt, TryStreamExt}; + +use distribution_types::{BuildableSource, Dist}; +use pep508_rs::{Requirement, VersionOrUrl}; +use uv_client::RegistryClient; +use uv_distribution::{Reporter, SourceDistCachedBuilder}; +use uv_types::{BuildContext, RequestedRequirements}; + +/// A resolver for resolving lookahead requirements from local dependencies. +/// +/// The resolver extends certain privileges to "first-party" requirements. For example, first-party +/// requirements are allowed to contain direct URL references, local version specifiers, and more. +/// +/// We make an exception for transitive requirements of _local_ dependencies. For example, +/// `pip install .` should treat the dependencies of `.` as if they were first-party dependencies. +/// This matches our treatment of editable installs (`pip install -e .`). +/// +/// The lookahead resolver resolves requirements for local dependencies, so that the resolver can +/// treat them as first-party dependencies for the purpose of analyzing their specifiers. +pub struct LookaheadResolver<'a> { + /// The requirements for the project. + requirements: &'a [Requirement], + /// The reporter to use when building source distributions. + reporter: Option>, +} + +impl<'a> LookaheadResolver<'a> { + /// Instantiate a new [`LookaheadResolver`] for a given set of `source_trees`. + pub fn new(requirements: &'a [Requirement]) -> Self { + Self { + requirements, + reporter: None, + } + } + + /// Set the [`Reporter`] to use for this resolver. + #[must_use] + pub fn with_reporter(self, reporter: impl Reporter + 'static) -> Self { + let reporter: Arc = Arc::new(reporter); + Self { + reporter: Some(reporter), + ..self + } + } + + /// Resolve the requirements from the provided source trees. + pub async fn resolve( + self, + context: &T, + client: &RegistryClient, + ) -> Result> { + let requirements: Vec<_> = futures::stream::iter(self.requirements.iter()) + .map(|requirement| async { self.lookahead(requirement, context, client).await }) + .buffered(50) + .try_collect() + .await?; + Ok(requirements.into_iter().flatten().collect()) + } + + /// Infer the package name for a given "unnamed" requirement. + async fn lookahead( + &self, + requirement: &Requirement, + context: &T, + client: &RegistryClient, + ) -> Result> { + // Determine whether the requirement represents a local distribution. + let Some(VersionOrUrl::Url(url)) = requirement.version_or_url.as_ref() else { + return Ok(None); + }; + + // Convert to a buildable distribution. + let dist = Dist::from_url(requirement.name.clone(), url.clone())?; + + // Only support source trees (and not, e.g., wheels). + let Dist::Source(source_dist) = &dist else { + return Ok(None); + }; + if !source_dist.as_path().is_some_and(std::path::Path::is_dir) { + return Ok(None); + } + + // Run the PEP 517 build process to extract metadata from the source distribution. + let builder = if let Some(reporter) = self.reporter.clone() { + SourceDistCachedBuilder::new(context, client).with_reporter(reporter) + } else { + SourceDistCachedBuilder::new(context, client) + }; + + let metadata = builder + .download_and_build_metadata(&BuildableSource::Dist(source_dist)) + .await + .context("Failed to build source distribution")?; + + // Return the requirements from the metadata. + Ok(Some(RequestedRequirements::new( + requirement.extras.clone(), + metadata.requires_dist, + ))) + } +} diff --git a/crates/uv-resolver/src/manifest.rs b/crates/uv-resolver/src/manifest.rs index d9eb6e44a64a..213a5d166171 100644 --- a/crates/uv-resolver/src/manifest.rs +++ b/crates/uv-resolver/src/manifest.rs @@ -2,18 +2,44 @@ use distribution_types::LocalEditable; use pep508_rs::Requirement; use pypi_types::Metadata23; use uv_normalize::PackageName; +use uv_types::RequestedRequirements; use crate::preferences::Preference; /// A manifest of requirements, constraints, and preferences. #[derive(Clone, Debug)] pub struct Manifest { + /// The direct requirements for the project. pub(crate) requirements: Vec, + + /// The constraints for the project. pub(crate) constraints: Vec, + + /// The overrides for the project. pub(crate) overrides: Vec, + + /// The preferences for the project. + /// + /// These represent "preferred" versions of a given package. For example, they may be the + /// versions that are already installed in the environment, or already pinned in an existing + /// lockfile. pub(crate) preferences: Vec, + + /// The name of the project. pub(crate) project: Option, + + /// The editable requirements for the project, which are built in advance. + /// + /// The requirements of the editables should be included in resolution as if they were + /// direct requirements in their own right. pub(crate) editables: Vec<(LocalEditable, Metadata23)>, + + /// The lookahead requirements for the project. + /// + /// These represent transitive dependencies that should be incorporated when making + /// determinations around "allowed" versions (for example, "allowed" URLs or "allowed" + /// pre-release versions). + pub(crate) lookaheads: Vec, } impl Manifest { @@ -24,6 +50,7 @@ impl Manifest { preferences: Vec, project: Option, editables: Vec<(LocalEditable, Metadata23)>, + lookaheads: Vec, ) -> Self { Self { requirements, @@ -32,6 +59,7 @@ impl Manifest { preferences, project, editables, + lookaheads, } } @@ -43,6 +71,7 @@ impl Manifest { preferences: Vec::new(), project: None, editables: Vec::new(), + lookaheads: Vec::new(), } } } diff --git a/crates/uv-resolver/src/prerelease_mode.rs b/crates/uv-resolver/src/prerelease_mode.rs index c8942d333572..905d7910bb5e 100644 --- a/crates/uv-resolver/src/prerelease_mode.rs +++ b/crates/uv-resolver/src/prerelease_mode.rs @@ -66,6 +66,11 @@ impl PreReleaseStrategy { .chain(manifest.constraints.iter()) .chain(manifest.overrides.iter()) .filter(|requirement| requirement.evaluate_markers(markers, &[])) + .chain(manifest.lookaheads.iter().flat_map(|lookahead| { + lookahead.requirements().iter().filter(|requirement| { + requirement.evaluate_markers(markers, lookahead.extras()) + }) + })) .chain(manifest.editables.iter().flat_map(|(editable, metadata)| { metadata.requires_dist.iter().filter(|requirement| { requirement.evaluate_markers(markers, &editable.extras) @@ -95,6 +100,11 @@ impl PreReleaseStrategy { .chain(manifest.constraints.iter()) .chain(manifest.overrides.iter()) .filter(|requirement| requirement.evaluate_markers(markers, &[])) + .chain(manifest.lookaheads.iter().flat_map(|lookahead| { + lookahead.requirements().iter().filter(|requirement| { + requirement.evaluate_markers(markers, lookahead.extras()) + }) + })) .chain(manifest.editables.iter().flat_map(|(editable, metadata)| { metadata.requires_dist.iter().filter(|requirement| { requirement.evaluate_markers(markers, &editable.extras) diff --git a/crates/uv-resolver/src/resolution_mode.rs b/crates/uv-resolver/src/resolution_mode.rs index c121598e2885..97a823e16ffb 100644 --- a/crates/uv-resolver/src/resolution_mode.rs +++ b/crates/uv-resolver/src/resolution_mode.rs @@ -41,11 +41,16 @@ impl ResolutionStrategy { ResolutionMode::Highest => Self::Highest, ResolutionMode::Lowest => Self::Lowest, ResolutionMode::LowestDirect => Self::LowestDirect( - // Consider `requirements` and dependencies of `editables` to be "direct" dependencies. + // Consider `requirements` and dependencies of any local requirements to be "direct" dependencies. manifest .requirements .iter() .filter(|requirement| requirement.evaluate_markers(markers, &[])) + .chain(manifest.lookaheads.iter().flat_map(|lookahead| { + lookahead.requirements().iter().filter(|requirement| { + requirement.evaluate_markers(markers, lookahead.extras()) + }) + })) .chain(manifest.editables.iter().flat_map(|(editable, metadata)| { metadata.requires_dist.iter().filter(|requirement| { requirement.evaluate_markers(markers, &editable.extras) diff --git a/crates/uv-resolver/src/resolver/locals.rs b/crates/uv-resolver/src/resolver/locals.rs index 5ae47fcd9843..dffd8e2398b7 100644 --- a/crates/uv-resolver/src/resolver/locals.rs +++ b/crates/uv-resolver/src/resolver/locals.rs @@ -24,28 +24,23 @@ impl Locals { // Add all direct requirements and constraints. There's no need to look for conflicts, // since conflicting versions will be tracked upstream. - for requirement in manifest - .requirements - .iter() - .filter(|requirement| requirement.evaluate_markers(markers, &[])) - .chain( - manifest - .constraints - .iter() - .filter(|requirement| requirement.evaluate_markers(markers, &[])), - ) - .chain(manifest.editables.iter().flat_map(|(editable, metadata)| { - metadata - .requires_dist - .iter() - .filter(|requirement| requirement.evaluate_markers(markers, &editable.extras)) - })) - .chain( - manifest - .overrides - .iter() - .filter(|requirement| requirement.evaluate_markers(markers, &[])), - ) + for requirement in + manifest + .requirements + .iter() + .chain(manifest.constraints.iter()) + .chain(manifest.overrides.iter()) + .filter(|requirement| requirement.evaluate_markers(markers, &[])) + .chain(manifest.lookaheads.iter().flat_map(|lookahead| { + lookahead.requirements().iter().filter(|requirement| { + requirement.evaluate_markers(markers, lookahead.extras()) + }) + })) + .chain(manifest.editables.iter().flat_map(|(editable, metadata)| { + metadata.requires_dist.iter().filter(|requirement| { + requirement.evaluate_markers(markers, &editable.extras) + }) + })) { if let Some(version_or_url) = requirement.version_or_url.as_ref() { for local in iter_locals(version_or_url) { diff --git a/crates/uv-resolver/src/resolver/urls.rs b/crates/uv-resolver/src/resolver/urls.rs index 9230f5627eeb..fa7f8e6a06cb 100644 --- a/crates/uv-resolver/src/resolver/urls.rs +++ b/crates/uv-resolver/src/resolver/urls.rs @@ -23,16 +23,39 @@ impl Urls { let mut required: FxHashMap = FxHashMap::default(); let mut allowed: FxHashMap = FxHashMap::default(); + // Add any lookahead requirements. If there are any conflicts, return an error. + for lookahead in &manifest.lookaheads { + for requirement in lookahead + .requirements() + .iter() + .filter(|requirement| requirement.evaluate_markers(markers, lookahead.extras())) + { + if let Some(pep508_rs::VersionOrUrl::Url(url)) = &requirement.version_or_url { + if let Some(previous) = required.insert(requirement.name.clone(), url.clone()) { + if !is_equal(&previous, url) { + if is_precise(&previous, url) { + debug!("Assuming {url} is a precise variant of {previous}"); + allowed.insert(url.clone(), previous); + } else { + return Err(ResolveError::ConflictingUrlsDirect( + requirement.name.clone(), + previous.verbatim().to_string(), + url.verbatim().to_string(), + )); + } + } + } + } + } + } + // Add all direct requirements and constraints. If there are any conflicts, return an error. for requirement in manifest .requirements .iter() .chain(manifest.constraints.iter()) + .filter(|requirement| requirement.evaluate_markers(markers, &[])) { - if !requirement.evaluate_markers(markers, &[]) { - continue; - } - if let Some(pep508_rs::VersionOrUrl::Url(url)) = &requirement.version_or_url { if let Some(previous) = required.insert(requirement.name.clone(), url.clone()) { if is_equal(&previous, url) { @@ -74,11 +97,11 @@ impl Urls { } } - for requirement in &metadata.requires_dist { - if !requirement.evaluate_markers(markers, &editable.extras) { - continue; - } - + for requirement in metadata + .requires_dist + .iter() + .filter(|requirement| requirement.evaluate_markers(markers, &editable.extras)) + { if let Some(pep508_rs::VersionOrUrl::Url(url)) = &requirement.version_or_url { if let Some(previous) = required.insert(requirement.name.clone(), url.clone()) { if !is_equal(&previous, url) { @@ -100,11 +123,11 @@ impl Urls { // Add any overrides. Conflicts here are fine, as the overrides are meant to be // authoritative. - for requirement in &manifest.overrides { - if !requirement.evaluate_markers(markers, &[]) { - continue; - } - + for requirement in manifest + .overrides + .iter() + .filter(|requirement| requirement.evaluate_markers(markers, &[])) + { if let Some(pep508_rs::VersionOrUrl::Url(url)) = &requirement.version_or_url { required.insert(requirement.name.clone(), url.clone()); } diff --git a/crates/uv-resolver/src/yanks.rs b/crates/uv-resolver/src/yanks.rs index 14b8a67a7b8f..139426c35e6c 100644 --- a/crates/uv-resolver/src/yanks.rs +++ b/crates/uv-resolver/src/yanks.rs @@ -1,7 +1,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use pep440_rs::Version; -use pep508_rs::MarkerEnvironment; +use pep508_rs::{MarkerEnvironment, VersionOrUrl}; use uv_normalize::PackageName; use crate::preferences::Preference; @@ -15,22 +15,26 @@ pub struct AllowedYanks(FxHashMap>); impl AllowedYanks { pub fn from_manifest(manifest: &Manifest, markers: &MarkerEnvironment) -> Self { let mut allowed_yanks = FxHashMap::>::default(); - for requirement in manifest - .requirements - .iter() - .chain(manifest.constraints.iter()) - .chain(manifest.overrides.iter()) - .chain(manifest.preferences.iter().map(Preference::requirement)) - .filter(|requirement| requirement.evaluate_markers(markers, &[])) - .chain(manifest.editables.iter().flat_map(|(editable, metadata)| { - metadata - .requires_dist - .iter() - .filter(|requirement| requirement.evaluate_markers(markers, &editable.extras)) - })) + for requirement in + manifest + .requirements + .iter() + .chain(manifest.constraints.iter()) + .chain(manifest.overrides.iter()) + .chain(manifest.preferences.iter().map(Preference::requirement)) + .filter(|requirement| requirement.evaluate_markers(markers, &[])) + .chain(manifest.lookaheads.iter().flat_map(|lookahead| { + lookahead.requirements().iter().filter(|requirement| { + requirement.evaluate_markers(markers, lookahead.extras()) + }) + })) + .chain(manifest.editables.iter().flat_map(|(editable, metadata)| { + metadata.requires_dist.iter().filter(|requirement| { + requirement.evaluate_markers(markers, &editable.extras) + }) + })) { - let Some(pep508_rs::VersionOrUrl::VersionSpecifier(specifiers)) = - &requirement.version_or_url + let Some(VersionOrUrl::VersionSpecifier(specifiers)) = &requirement.version_or_url else { continue; }; diff --git a/crates/uv-resolver/tests/resolver.rs b/crates/uv-resolver/tests/resolver.rs index 5ef1c5cca225..de721d6b9131 100644 --- a/crates/uv-resolver/tests/resolver.rs +++ b/crates/uv-resolver/tests/resolver.rs @@ -271,6 +271,7 @@ async fn black_mypy_extensions() -> Result<()> { vec![], None, vec![], + vec![], ); let options = OptionsBuilder::new() .exclude_newer(Some(*EXCLUDE_NEWER)) @@ -306,6 +307,7 @@ async fn black_mypy_extensions_extra() -> Result<()> { vec![], None, vec![], + vec![], ); let options = OptionsBuilder::new() .exclude_newer(Some(*EXCLUDE_NEWER)) @@ -341,6 +343,7 @@ async fn black_flake8() -> Result<()> { vec![], None, vec![], + vec![], ); let options = OptionsBuilder::new() .exclude_newer(Some(*EXCLUDE_NEWER)) @@ -430,6 +433,7 @@ async fn black_respect_preference() -> Result<()> { )?)], None, vec![], + vec![], ); let options = OptionsBuilder::new() .exclude_newer(Some(*EXCLUDE_NEWER)) @@ -465,6 +469,7 @@ async fn black_ignore_preference() -> Result<()> { )?)], None, vec![], + vec![], ); let options = OptionsBuilder::new() .exclude_newer(Some(*EXCLUDE_NEWER)) diff --git a/crates/uv-types/src/lib.rs b/crates/uv-types/src/lib.rs index 64d0fbfd2be6..c729393c48ec 100644 --- a/crates/uv-types/src/lib.rs +++ b/crates/uv-types/src/lib.rs @@ -4,6 +4,7 @@ pub use config_settings::*; pub use downloads::*; pub use name_specifiers::*; pub use package_options::*; +pub use requirements::*; pub use traits::*; mod build_options; @@ -11,4 +12,5 @@ mod config_settings; mod downloads; mod name_specifiers; mod package_options; +mod requirements; mod traits; diff --git a/crates/uv-types/src/requirements.rs b/crates/uv-types/src/requirements.rs new file mode 100644 index 000000000000..8b8c4b851260 --- /dev/null +++ b/crates/uv-types/src/requirements.rs @@ -0,0 +1,35 @@ +use pep508_rs::Requirement; +use uv_normalize::ExtraName; + +/// A set of requirements as requested by a parent requirement. +/// +/// For example, given `flask[dotenv]`, the `RequestedRequirements` would include the `dotenv` +/// extra, along with all of the requirements that are included in the `flask` distribution +/// including their unevaluated markers. +#[derive(Debug, Clone)] +pub struct RequestedRequirements { + /// The set of extras included on the originating requirement. + extras: Vec, + /// The set of requirements that were requested by the originating requirement. + requirements: Vec, +} + +impl RequestedRequirements { + /// Instantiate a [`RequestedRequirements`] with the given `extras` and `requirements`. + pub fn new(extras: Vec, requirements: Vec) -> Self { + Self { + extras, + requirements, + } + } + + /// Return the extras that were included on the originating requirement. + pub fn extras(&self) -> &[ExtraName] { + &self.extras + } + + /// Return the requirements that were included on the originating requirement. + pub fn requirements(&self) -> &[Requirement] { + &self.requirements + } +} diff --git a/crates/uv/src/commands/pip_compile.rs b/crates/uv/src/commands/pip_compile.rs index 16fd92db653b..f3b78f9f085d 100644 --- a/crates/uv/src/commands/pip_compile.rs +++ b/crates/uv/src/commands/pip_compile.rs @@ -28,8 +28,8 @@ use uv_installer::{Downloader, NoBinary}; use uv_interpreter::{find_best_python, PythonEnvironment, PythonVersion}; use uv_normalize::{ExtraName, PackageName}; use uv_requirements::{ - upgrade::read_lockfile, ExtrasSpecification, NamedRequirementsResolver, RequirementsSource, - RequirementsSpecification, SourceTreeResolver, + upgrade::read_lockfile, ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, + RequirementsSource, RequirementsSpecification, SourceTreeResolver, }; use uv_resolver::{ AnnotationStyle, DependencyMode, DisplayResolutionGraph, InMemoryIndex, Manifest, @@ -274,6 +274,12 @@ pub(crate) async fn pip_compile( requirements }; + // Determine any lookahead requirements. + let lookaheads = LookaheadResolver::new(&requirements) + .with_reporter(ResolverReporter::from(printer)) + .resolve(&build_dispatch, &client) + .await?; + // Build the editables and add their requirements let editable_metadata = if editables.is_empty() { Vec::new() @@ -338,6 +344,7 @@ pub(crate) async fn pip_compile( preferences, project, editable_metadata, + lookaheads, ); let options = OptionsBuilder::new() diff --git a/crates/uv/src/commands/pip_install.rs b/crates/uv/src/commands/pip_install.rs index 4594dde2d489..e97433675ec7 100644 --- a/crates/uv/src/commands/pip_install.rs +++ b/crates/uv/src/commands/pip_install.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use std::fmt::Write; use std::path::Path; -use std::time::Instant; use anstream::eprint; use anyhow::{anyhow, Context, Result}; @@ -34,8 +33,8 @@ use uv_installer::{ use uv_interpreter::{Interpreter, PythonEnvironment}; use uv_normalize::PackageName; use uv_requirements::{ - ExtrasSpecification, NamedRequirementsResolver, RequirementsSource, RequirementsSpecification, - SourceTreeResolver, + ExtrasSpecification, LookaheadResolver, NamedRequirementsResolver, RequirementsSource, + RequirementsSpecification, SourceTreeResolver, }; use uv_resolver::{ DependencyMode, InMemoryIndex, Manifest, Options, OptionsBuilder, PreReleaseMode, Preference, @@ -82,7 +81,7 @@ pub(crate) async fn pip_install( dry_run: bool, printer: Printer, ) -> Result { - let start = Instant::now(); + let start = std::time::Instant::now(); let client_builder = BaseClientBuilder::new() .connectivity(connectivity) .native_tls(native_tls) @@ -437,7 +436,7 @@ async fn build_editables( build_dispatch: &BuildDispatch<'_>, printer: Printer, ) -> Result, Error> { - let start = Instant::now(); + let start = std::time::Instant::now(); let downloader = Downloader::new(cache, tags, client, build_dispatch) .with_reporter(DownloadReporter::from(printer).with_length(editables.len() as u64)); @@ -547,6 +546,12 @@ async fn resolve( }) .collect(); + // Determine any lookahead requirements. + let lookaheads = LookaheadResolver::new(&requirements) + .with_reporter(ResolverReporter::from(printer)) + .resolve(build_dispatch, client) + .await?; + // Create a manifest of the requirements. let manifest = Manifest::new( requirements, @@ -555,6 +560,7 @@ async fn resolve( preferences, project, editables, + lookaheads, ); // Resolve the dependencies. @@ -674,7 +680,7 @@ async fn install( let wheels = if remote.is_empty() { vec![] } else { - let start = Instant::now(); + let start = std::time::Instant::now(); let downloader = Downloader::new(cache, tags, client, build_dispatch) .with_reporter(DownloadReporter::from(printer).with_length(remote.len() as u64)); @@ -796,7 +802,7 @@ async fn install( fn report_dry_run( resolution: &Resolution, plan: Plan, - start: Instant, + start: std::time::Instant, printer: Printer, ) -> Result<(), Error> { let Plan { diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index f788e2bd23bd..48f27f025cf9 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -1691,7 +1691,7 @@ fn incompatible_narrowed_url_dependency() -> Result<()> { Ok(()) } -/// Request `transitive_url_dependency`, which depends on `https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl`. +/// Request `hatchling_editable`, which depends on `https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl`. /// Since this URL isn't declared upfront, we should reject it. #[test] #[cfg(feature = "git")] @@ -1699,12 +1699,10 @@ fn disallowed_transitive_url_dependency() -> Result<()> { let context = TestContext::new("3.12"); let requirements_in = context.temp_dir.child("requirements.in"); - requirements_in.write_str("hatchling_editable @ ${HATCHLING}")?; + requirements_in.write_str("hatchling_editable @ https://github.com/astral-sh/uv/files/14762645/hatchling_editable.zip")?; - let hatchling_path = current_dir()?.join("../../scripts/packages/hatchling_editable"); uv_snapshot!(context.compile() - .arg("requirements.in") - .env("HATCHLING", hatchling_path.as_os_str()), @r###" + .arg("requirements.in"), @r###" success: false exit_code: 2 ----- stdout ----- @@ -1725,23 +1723,21 @@ fn allowed_transitive_url_dependency() -> Result<()> { let context = TestContext::new("3.12"); let requirements_in = context.temp_dir.child("requirements.in"); - requirements_in.write_str("hatchling_editable @ ${HATCHLING}")?; + requirements_in.write_str("hatchling_editable @ https://github.com/astral-sh/uv/files/14762645/hatchling_editable.zip")?; let constraints_txt = context.temp_dir.child("constraints.txt"); constraints_txt.write_str("iniconfig @ git+https://github.com/pytest-dev/iniconfig@9cae43103df70bac6fde7b9f35ad11a9f1be0cb4")?; - let hatchling_path = current_dir()?.join("../../scripts/packages/hatchling_editable"); uv_snapshot!(context.compile() .arg("requirements.in") .arg("--constraint") - .arg("constraints.txt") - .env("HATCHLING", hatchling_path.as_os_str()), @r###" + .arg("constraints.txt"), @r###" success: true exit_code: 0 ----- stdout ----- # This file was autogenerated by uv via the following command: # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z requirements.in --constraint constraints.txt - hatchling-editable @ ${HATCHLING} + hatchling-editable @ https://github.com/astral-sh/uv/files/14762645/hatchling_editable.zip iniconfig @ git+https://github.com/pytest-dev/iniconfig@9cae43103df70bac6fde7b9f35ad11a9f1be0cb4 # via hatchling-editable @@ -1762,23 +1758,21 @@ fn allowed_transitive_canonical_url_dependency() -> Result<()> { let context = TestContext::new("3.12"); let requirements_in = context.temp_dir.child("requirements.in"); - requirements_in.write_str("hatchling_editable @ ${HATCHLING}")?; + requirements_in.write_str("hatchling_editable @ https://github.com/astral-sh/uv/files/14762645/hatchling_editable.zip")?; let constraints_txt = context.temp_dir.child("constraints.txt"); constraints_txt.write_str("iniconfig @ git+https://github.com/pytest-dev/iniconfig.git@9cae43103df70bac6fde7b9f35ad11a9f1be0cb4")?; - let hatchling_path = current_dir()?.join("../../scripts/packages/hatchling_editable"); uv_snapshot!(context.compile() .arg("requirements.in") .arg("--constraint") - .arg("constraints.txt") - .env("HATCHLING", hatchling_path.as_os_str()), @r###" + .arg("constraints.txt"), @r###" success: true exit_code: 0 ----- stdout ----- # This file was autogenerated by uv via the following command: # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z requirements.in --constraint constraints.txt - hatchling-editable @ ${HATCHLING} + hatchling-editable @ https://github.com/astral-sh/uv/files/14762645/hatchling_editable.zip iniconfig @ git+https://github.com/pytest-dev/iniconfig.git@9cae43103df70bac6fde7b9f35ad11a9f1be0cb4 # via hatchling-editable @@ -1790,6 +1784,37 @@ fn allowed_transitive_canonical_url_dependency() -> Result<()> { Ok(()) } +/// Request `hatchling_editable`, which depends on `https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl`. +/// Since `hatchling_editable` is a path (local) dependency, we should accept it. +#[test] +#[cfg(feature = "git")] +fn allowed_transitive_url_path_dependency() -> Result<()> { + let context = TestContext::new("3.12"); + + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("hatchling_editable @ ${HATCH_PATH}")?; + + let hatchling_path = current_dir()?.join("../../scripts/packages/hatchling_editable"); + uv_snapshot!(context.compile() + .arg("requirements.in") + .env("HATCH_PATH", hatchling_path.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z requirements.in + hatchling-editable @ ${HATCH_PATH} + iniconfig @ git+https://github.com/pytest-dev/iniconfig@9cae43103df70bac6fde7b9f35ad11a9f1be0cb4 + # via hatchling-editable + + ----- stderr ----- + Resolved 2 packages in [TIME] + "### + ); + + Ok(()) +} + /// Resolve packages from all optional dependency groups in a `pyproject.toml` file. #[test] fn compile_pyproject_toml_all_extras() -> Result<()> { @@ -6328,3 +6353,110 @@ fn pendulum_no_tzdata_on_windows() -> Result<()> { Ok(()) } + +/// Allow pre-releases for dependencies of source path requirements. +#[test] +fn pre_release_path_requirement() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create an a package that requires a pre-release version of `flask`. + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#"[project] +name = "example" +version = "0.0.0" +dependencies = [ + "flask==2.0.0rc1" +] +requires-python = ">3.8" +"#, + )?; + + // Write to a requirements file. + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str(".")?; + + uv_snapshot!(context.compile() + .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] --exclude-newer 2024-03-25T00:00:00Z requirements.in + click==8.1.7 + # via flask + example @ . + flask==2.0.0rc1 + # via example + itsdangerous==2.1.2 + # via flask + jinja2==3.1.3 + # via flask + markupsafe==2.1.5 + # via + # jinja2 + # werkzeug + werkzeug==3.0.1 + # via flask + + ----- stderr ----- + Resolved 7 packages in [TIME] + "### + ); + + Ok(()) +} + +/// Allow pre-releases for dependencies of editable requirements. +#[test] +fn pre_release_editable_requirement() -> Result<()> { + let context = TestContext::new("3.12"); + + // Create an a package that requires a pre-release version of `flask`.r + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#"[project] +name = "example" +version = "0.0.0" +dependencies = [ + "flask==2.0.0rc1" +] +requires-python = ">3.8" +"#, + )?; + + // Write to a requirements file. + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("-e .")?; + + uv_snapshot!( context.compile() + .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] --exclude-newer 2024-03-25T00:00:00Z requirements.in + -e . + click==8.1.7 + # via flask + flask==2.0.0rc1 + # via example + itsdangerous==2.1.2 + # via flask + jinja2==3.1.3 + # via flask + markupsafe==2.1.5 + # via + # jinja2 + # werkzeug + werkzeug==3.0.1 + # via flask + + ----- stderr ----- + Built 1 editable in [TIME] + Resolved 7 packages in [TIME] + "### + ); + + Ok(()) +}