diff --git a/Cargo.lock b/Cargo.lock index ebd1c121a..6864b4319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6014,6 +6014,7 @@ dependencies = [ "tracing", "uv-build-backend", "uv-cache-key", + "uv-configuration", "uv-distribution-types", "uv-fs", "uv-git-types", diff --git a/crates/uv-bench/benches/uv.rs b/crates/uv-bench/benches/uv.rs index 95106a52b..9bdd7adb9 100644 --- a/crates/uv-bench/benches/uv.rs +++ b/crates/uv-bench/benches/uv.rs @@ -91,7 +91,7 @@ mod resolver { }; use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution::DistributionDatabase; - use uv_distribution_types::{DependencyMetadata, IndexLocations}; + use uv_distribution_types::{DependencyMetadata, IndexLocations, RequiresPython}; use uv_install_wheel::LinkMode; use uv_pep440::Version; use uv_pep508::{MarkerEnvironment, MarkerEnvironmentBuilder}; @@ -99,8 +99,8 @@ mod resolver { use uv_pypi_types::{Conflicts, ResolverMarkerEnvironment}; use uv_python::Interpreter; use uv_resolver::{ - FlatIndex, InMemoryIndex, Manifest, OptionsBuilder, PythonRequirement, RequiresPython, - Resolver, ResolverEnvironment, ResolverOutput, + FlatIndex, InMemoryIndex, Manifest, OptionsBuilder, PythonRequirement, Resolver, + ResolverEnvironment, ResolverOutput, }; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_workspace::WorkspaceCache; diff --git a/crates/uv-configuration/src/dependency_groups.rs b/crates/uv-configuration/src/dependency_groups.rs index 345f4077c..a3b90ea5f 100644 --- a/crates/uv-configuration/src/dependency_groups.rs +++ b/crates/uv-configuration/src/dependency_groups.rs @@ -295,6 +295,15 @@ pub struct DependencyGroupsWithDefaults { } impl DependencyGroupsWithDefaults { + /// Do not enable any groups + /// + /// Many places in the code need to know what dependency-groups are active, + /// but various commands or subsystems never enable any dependency-groups, + /// in which case they want this. + pub fn none() -> Self { + DependencyGroups::default().with_defaults(DefaultGroups::default()) + } + /// Returns `true` if the specification was enabled, and *only* because it was a default pub fn contains_because_default(&self, group: &GroupName) -> bool { self.cur.contains(group) && !self.prev.contains(group) diff --git a/crates/uv-configuration/src/extras.rs b/crates/uv-configuration/src/extras.rs index 3bc9da21a..e39fc72ef 100644 --- a/crates/uv-configuration/src/extras.rs +++ b/crates/uv-configuration/src/extras.rs @@ -263,6 +263,14 @@ pub struct ExtrasSpecificationWithDefaults { } impl ExtrasSpecificationWithDefaults { + /// Do not enable any extras + /// + /// Many places in the code need to know what extras are active, + /// but various commands or subsystems never enable any extras, + /// in which case they want this. + pub fn none() -> Self { + ExtrasSpecification::default().with_defaults(DefaultExtras::default()) + } /// Returns `true` if the specification was enabled, and *only* because it was a default pub fn contains_because_default(&self, extra: &ExtraName) -> bool { self.cur.contains(extra) && !self.prev.contains(extra) diff --git a/crates/uv-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index 44030ffee..1e3ad7eba 100644 --- a/crates/uv-distribution-types/src/lib.rs +++ b/crates/uv-distribution-types/src/lib.rs @@ -73,6 +73,7 @@ pub use crate::pip_index::*; pub use crate::prioritized_distribution::*; pub use crate::requested::*; pub use crate::requirement::*; +pub use crate::requires_python::*; pub use crate::resolution::*; pub use crate::resolved::*; pub use crate::specified_requirement::*; @@ -100,6 +101,7 @@ mod pip_index; mod prioritized_distribution; mod requested; mod requirement; +mod requires_python; mod resolution; mod resolved; mod specified_requirement; diff --git a/crates/uv-resolver/src/requires_python.rs b/crates/uv-distribution-types/src/requires_python.rs similarity index 97% rename from crates/uv-resolver/src/requires_python.rs rename to crates/uv-distribution-types/src/requires_python.rs index 8e4d33213..ae9fee7fe 100644 --- a/crates/uv-resolver/src/requires_python.rs +++ b/crates/uv-distribution-types/src/requires_python.rs @@ -1,6 +1,6 @@ use std::collections::Bound; -use pubgrub::Range; +use version_ranges::Ranges; use uv_distribution_filename::WheelFilename; use uv_pep440::{ @@ -68,7 +68,7 @@ impl RequiresPython { let range = specifiers .into_iter() .map(|specifier| release_specifiers_to_ranges(specifier.clone())) - .fold(None, |range: Option>, requires_python| { + .fold(None, |range: Option>, requires_python| { if let Some(range) = range { Some(range.intersection(&requires_python)) } else { @@ -97,12 +97,12 @@ impl RequiresPython { pub fn split(&self, bound: Bound) -> Option<(Self, Self)> { let RequiresPythonRange(.., upper) = &self.range; - let upper = Range::from_range_bounds((bound, upper.clone().into())); + let upper = Ranges::from_range_bounds((bound, upper.clone().into())); let lower = upper.complement(); // Intersect left and right with the existing range. - let lower = lower.intersection(&Range::from(self.range.clone())); - let upper = upper.intersection(&Range::from(self.range.clone())); + let lower = lower.intersection(&Ranges::from(self.range.clone())); + let upper = upper.intersection(&Ranges::from(self.range.clone())); if lower.is_empty() || upper.is_empty() { None @@ -353,7 +353,7 @@ impl RequiresPython { /// a lock file are deserialized and turned into a `ResolutionGraph`, the /// markers are "complexified" to put the `requires-python` assumption back /// into the marker explicitly. - pub(crate) fn simplify_markers(&self, marker: MarkerTree) -> MarkerTree { + pub fn simplify_markers(&self, marker: MarkerTree) -> MarkerTree { let (lower, upper) = (self.range().lower(), self.range().upper()); marker.simplify_python_versions(lower.as_ref(), upper.as_ref()) } @@ -373,7 +373,7 @@ impl RequiresPython { /// ```text /// python_full_version >= '3.8' and python_full_version < '3.12' /// ``` - pub(crate) fn complexify_markers(&self, marker: MarkerTree) -> MarkerTree { + pub fn complexify_markers(&self, marker: MarkerTree) -> MarkerTree { let (lower, upper) = (self.range().lower(), self.range().upper()); marker.complexify_python_versions(lower.as_ref(), upper.as_ref()) } @@ -537,7 +537,7 @@ pub struct RequiresPythonRange(LowerBound, UpperBound); impl RequiresPythonRange { /// Initialize a [`RequiresPythonRange`] from a [`Range`]. - pub fn from_range(range: &Range) -> Self { + pub fn from_range(range: &Ranges) -> Self { let (lower, upper) = range .bounding_range() .map(|(lower_bound, upper_bound)| (lower_bound.cloned(), upper_bound.cloned())) @@ -575,9 +575,9 @@ impl Default for RequiresPythonRange { } } -impl From for Range { +impl From for Ranges { fn from(value: RequiresPythonRange) -> Self { - Range::from_range_bounds::<(Bound, Bound), _>(( + Ranges::from_range_bounds::<(Bound, Bound), _>(( value.0.into(), value.1.into(), )) @@ -592,21 +592,18 @@ impl From for Range { /// a simplified marker, one must re-contextualize it by adding the /// `requires-python` constraint back to the marker. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, PartialOrd, Ord, serde::Deserialize)] -pub(crate) struct SimplifiedMarkerTree(MarkerTree); +pub struct SimplifiedMarkerTree(MarkerTree); impl SimplifiedMarkerTree { /// Simplifies the given markers by assuming the given `requires-python` /// bound is true. - pub(crate) fn new( - requires_python: &RequiresPython, - marker: MarkerTree, - ) -> SimplifiedMarkerTree { + pub fn new(requires_python: &RequiresPython, marker: MarkerTree) -> SimplifiedMarkerTree { SimplifiedMarkerTree(requires_python.simplify_markers(marker)) } /// Complexifies the given markers by adding the given `requires-python` as /// a constraint to these simplified markers. - pub(crate) fn into_marker(self, requires_python: &RequiresPython) -> MarkerTree { + pub fn into_marker(self, requires_python: &RequiresPython) -> MarkerTree { requires_python.complexify_markers(self.0) } @@ -614,12 +611,12 @@ impl SimplifiedMarkerTree { /// /// This only returns `None` when the underlying marker is always true, /// i.e., it matches all possible marker environments. - pub(crate) fn try_to_string(self) -> Option { + pub fn try_to_string(self) -> Option { self.0.try_to_string() } /// Returns the underlying marker tree without re-complexifying them. - pub(crate) fn as_simplified_marker_tree(self) -> MarkerTree { + pub fn as_simplified_marker_tree(self) -> MarkerTree { self.0 } } diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index d728ed58b..e9f36f174 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -6,7 +6,7 @@ use rustc_hash::FxHashSet; use uv_configuration::SourceStrategy; use uv_distribution_types::{IndexLocations, Requirement}; -use uv_normalize::{DEV_DEPENDENCIES, ExtraName, GroupName, PackageName}; +use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep508::MarkerTree; use uv_workspace::dependency_groups::FlatDependencyGroups; use uv_workspace::pyproject::{Sources, ToolUvSources}; @@ -107,41 +107,10 @@ impl RequiresDist { SourceStrategy::Disabled => &empty, }; - // Collect the dependency groups. - let dependency_groups = { - // First, collect `tool.uv.dev_dependencies` - let dev_dependencies = project_workspace - .current_project() - .pyproject_toml() - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.dev_dependencies.as_ref()); - - // Then, collect `dependency-groups` - let dependency_groups = project_workspace - .current_project() - .pyproject_toml() - .dependency_groups - .iter() - .flatten() - .collect::>(); - - // Flatten the dependency groups. - let mut dependency_groups = - FlatDependencyGroups::from_dependency_groups(&dependency_groups) - .map_err(|err| err.with_dev_dependencies(dev_dependencies))?; - - // Add the `dev` group, if `dev-dependencies` is defined. - if let Some(dev_dependencies) = dev_dependencies { - dependency_groups - .entry(DEV_DEPENDENCIES.clone()) - .or_insert_with(Vec::new) - .extend(dev_dependencies.clone()); - } - - dependency_groups - }; + let dependency_groups = FlatDependencyGroups::from_pyproject_toml( + project_workspace.current_project().root(), + project_workspace.current_project().pyproject_toml(), + )?; // Now that we've resolved the dependency groups, we can validate that each source references // a valid extra or group, if present. @@ -150,9 +119,10 @@ impl RequiresDist { // Lower the dependency groups. let dependency_groups = dependency_groups .into_iter() - .map(|(name, requirements)| { + .map(|(name, flat_group)| { let requirements = match source_strategy { - SourceStrategy::Enabled => requirements + SourceStrategy::Enabled => flat_group + .requirements .into_iter() .flat_map(|requirement| { let requirement_name = requirement.name.clone(); @@ -182,9 +152,11 @@ impl RequiresDist { ) }) .collect::, _>>(), - SourceStrategy::Disabled => { - Ok(requirements.into_iter().map(Requirement::from).collect()) - } + SourceStrategy::Disabled => Ok(flat_group + .requirements + .into_iter() + .map(Requirement::from) + .collect()), }?; Ok::<(GroupName, Box<_>), MetadataError>((name, requirements)) }) @@ -265,7 +237,7 @@ impl RequiresDist { if let Some(group) = source.group() { // If the group doesn't exist at all, error. - let Some(dependencies) = dependency_groups.get(group) else { + let Some(flat_group) = dependency_groups.get(group) else { return Err(MetadataError::MissingSourceGroup( name.clone(), group.clone(), @@ -273,7 +245,8 @@ impl RequiresDist { }; // If there is no such requirement with the group, error. - if !dependencies + if !flat_group + .requirements .iter() .any(|requirement| requirement.name == *name) { diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 3285f9a6a..48904660d 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -14,7 +14,6 @@ pub use options::{Flexibility, Options, OptionsBuilder}; pub use preferences::{Preference, PreferenceError, Preferences}; pub use prerelease::PrereleaseMode; pub use python_requirement::PythonRequirement; -pub use requires_python::{RequiresPython, RequiresPythonRange}; pub use resolution::{ AnnotationStyle, ConflictingDistributionError, DisplayResolutionGraph, ResolverOutput, }; @@ -58,7 +57,6 @@ mod prerelease; mod pubgrub; mod python_requirement; mod redirect; -mod requires_python; mod resolution; mod resolution_mode; mod resolver; diff --git a/crates/uv-resolver/src/lock/export/pylock_toml.rs b/crates/uv-resolver/src/lock/export/pylock_toml.rs index 4f3e885ab..d2c2383a5 100644 --- a/crates/uv-resolver/src/lock/export/pylock_toml.rs +++ b/crates/uv-resolver/src/lock/export/pylock_toml.rs @@ -23,8 +23,8 @@ use uv_distribution_filename::{ use uv_distribution_types::{ BuiltDist, DirectUrlBuiltDist, DirectUrlSourceDist, DirectorySourceDist, Dist, Edge, FileLocation, GitSourceDist, IndexUrl, Name, Node, PathBuiltDist, PathSourceDist, - RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource, Resolution, - ResolvedDist, SourceDist, ToUrlError, UrlString, + RegistryBuiltDist, RegistryBuiltWheel, RegistrySourceDist, RemoteSource, RequiresPython, + Resolution, ResolvedDist, SourceDist, ToUrlError, UrlString, }; use uv_fs::{PortablePathBuf, relative_to}; use uv_git::{RepositoryReference, ResolvedRepositoryReference}; @@ -40,7 +40,7 @@ use uv_small_str::SmallString; use crate::lock::export::ExportableRequirements; use crate::lock::{Source, WheelTagHint, each_element_on_its_line_array}; use crate::resolution::ResolutionGraphNode; -use crate::{Installable, LockError, RequiresPython, ResolverOutput}; +use crate::{Installable, LockError, ResolverOutput}; #[derive(Debug, thiserror::Error)] pub enum PylockTomlErrorKind { diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index faacae736..47597f2ec 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -29,8 +29,8 @@ use uv_distribution_types::{ BuiltDist, DependencyMetadata, DirectUrlBuiltDist, DirectUrlSourceDist, DirectorySourceDist, Dist, DistributionMetadata, FileLocation, GitSourceDist, IndexLocations, IndexMetadata, IndexUrl, Name, PathBuiltDist, PathSourceDist, RegistryBuiltDist, RegistryBuiltWheel, - RegistrySourceDist, RemoteSource, Requirement, RequirementSource, ResolvedDist, StaticMetadata, - ToUrlError, UrlString, + RegistrySourceDist, RemoteSource, Requirement, RequirementSource, RequiresPython, ResolvedDist, + SimplifiedMarkerTree, StaticMetadata, ToUrlError, UrlString, }; use uv_fs::{PortablePath, PortablePathBuf, relative_to}; use uv_git::{RepositoryReference, ResolvedRepositoryReference}; @@ -57,12 +57,10 @@ pub use crate::lock::export::{PylockToml, PylockTomlErrorKind}; pub use crate::lock::installable::Installable; pub use crate::lock::map::PackageMap; pub use crate::lock::tree::TreeDisplay; -use crate::requires_python::SimplifiedMarkerTree; use crate::resolution::{AnnotatedDist, ResolutionGraphNode}; use crate::universal_marker::{ConflictMarker, UniversalMarker}; use crate::{ - ExcludeNewer, InMemoryIndex, MetadataResponse, PrereleaseMode, RequiresPython, ResolutionMode, - ResolverOutput, + ExcludeNewer, InMemoryIndex, MetadataResponse, PrereleaseMode, ResolutionMode, ResolverOutput, }; mod export; diff --git a/crates/uv-resolver/src/marker.rs b/crates/uv-resolver/src/marker.rs index 1bb938a33..b63d51401 100644 --- a/crates/uv-resolver/src/marker.rs +++ b/crates/uv-resolver/src/marker.rs @@ -5,7 +5,7 @@ use std::ops::Bound; use uv_pep440::{LowerBound, UpperBound, Version}; use uv_pep508::{CanonicalMarkerValueVersion, MarkerTree, MarkerTreeKind}; -use crate::requires_python::RequiresPythonRange; +use uv_distribution_types::RequiresPythonRange; /// Returns the bounding Python versions that can satisfy the [`MarkerTree`], if it's constrained. pub(crate) fn requires_python(tree: MarkerTree) -> Option { diff --git a/crates/uv-resolver/src/pubgrub/report.rs b/crates/uv-resolver/src/pubgrub/report.rs index b7b83a19b..91f8d4baa 100644 --- a/crates/uv-resolver/src/pubgrub/report.rs +++ b/crates/uv-resolver/src/pubgrub/report.rs @@ -11,7 +11,7 @@ use rustc_hash::FxHashMap; use uv_configuration::{IndexStrategy, NoBinary, NoBuild}; use uv_distribution_types::{ IncompatibleDist, IncompatibleSource, IncompatibleWheel, Index, IndexCapabilities, - IndexLocations, IndexMetadata, IndexUrl, + IndexLocations, IndexMetadata, IndexUrl, RequiresPython, }; use uv_normalize::PackageName; use uv_pep440::{Version, VersionSpecifiers}; @@ -27,9 +27,7 @@ use crate::python_requirement::{PythonRequirement, PythonRequirementSource}; use crate::resolver::{ MetadataUnavailable, UnavailablePackage, UnavailableReason, UnavailableVersion, }; -use crate::{ - Flexibility, InMemoryIndex, Options, RequiresPython, ResolverEnvironment, VersionsResponse, -}; +use crate::{Flexibility, InMemoryIndex, Options, ResolverEnvironment, VersionsResponse}; #[derive(Debug)] pub(crate) struct PubGrubReportFormatter<'a> { diff --git a/crates/uv-resolver/src/python_requirement.rs b/crates/uv-resolver/src/python_requirement.rs index 178b77866..0dce9b4f7 100644 --- a/crates/uv-resolver/src/python_requirement.rs +++ b/crates/uv-resolver/src/python_requirement.rs @@ -1,11 +1,10 @@ use std::collections::Bound; +use uv_distribution_types::{RequiresPython, RequiresPythonRange}; use uv_pep440::Version; use uv_pep508::{MarkerEnvironment, MarkerTree}; use uv_python::{Interpreter, PythonVersion}; -use crate::{RequiresPython, RequiresPythonRange}; - #[derive(Debug, Clone, Eq, PartialEq)] pub struct PythonRequirement { source: PythonRequirementSource, diff --git a/crates/uv-resolver/src/resolution/output.rs b/crates/uv-resolver/src/resolution/output.rs index 5df5ae6c3..928b9c605 100644 --- a/crates/uv-resolver/src/resolution/output.rs +++ b/crates/uv-resolver/src/resolution/output.rs @@ -12,8 +12,8 @@ use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; use uv_configuration::{Constraints, Overrides}; use uv_distribution::Metadata; use uv_distribution_types::{ - Dist, DistributionMetadata, Edge, IndexUrl, Name, Node, Requirement, ResolutionDiagnostic, - ResolvedDist, VersionId, VersionOrUrlRef, + Dist, DistributionMetadata, Edge, IndexUrl, Name, Node, Requirement, RequiresPython, + ResolutionDiagnostic, ResolvedDist, VersionId, VersionOrUrlRef, }; use uv_git::GitResolver; use uv_normalize::{ExtraName, GroupName, PackageName}; @@ -30,8 +30,7 @@ use crate::resolution_mode::ResolutionStrategy; use crate::resolver::{Resolution, ResolutionDependencyEdge, ResolutionPackage}; use crate::universal_marker::{ConflictMarker, UniversalMarker}; use crate::{ - InMemoryIndex, MetadataResponse, Options, PythonRequirement, RequiresPython, ResolveError, - VersionsResponse, + InMemoryIndex, MetadataResponse, Options, PythonRequirement, ResolveError, VersionsResponse, }; /// The output of a successful resolution. diff --git a/crates/uv-resolver/src/resolution/requirements_txt.rs b/crates/uv-resolver/src/resolution/requirements_txt.rs index 5ad6480c2..bcdef207b 100644 --- a/crates/uv-resolver/src/resolution/requirements_txt.rs +++ b/crates/uv-resolver/src/resolution/requirements_txt.rs @@ -4,16 +4,16 @@ use std::path::Path; use itertools::Itertools; -use uv_distribution_types::{DistributionMetadata, Name, ResolvedDist, Verbatim, VersionOrUrlRef}; +use uv_distribution_types::{ + DistributionMetadata, Name, RequiresPython, ResolvedDist, SimplifiedMarkerTree, Verbatim, + VersionOrUrlRef, +}; use uv_normalize::{ExtraName, PackageName}; use uv_pep440::Version; use uv_pep508::{MarkerTree, Scheme, split_scheme}; use uv_pypi_types::HashDigest; -use crate::{ - requires_python::{RequiresPython, SimplifiedMarkerTree}, - resolution::AnnotatedDist, -}; +use crate::resolution::AnnotatedDist; #[derive(Debug, Clone)] /// A pinned package with its resolved distribution and all the extras that were pinned for it. diff --git a/crates/uv-resolver/src/resolver/environment.rs b/crates/uv-resolver/src/resolver/environment.rs index 354941886..6e816f991 100644 --- a/crates/uv-resolver/src/resolver/environment.rs +++ b/crates/uv-resolver/src/resolver/environment.rs @@ -1,14 +1,14 @@ use std::sync::Arc; use tracing::trace; +use uv_distribution_types::{RequiresPython, RequiresPythonRange}; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerEnvironment, MarkerTree}; use uv_pypi_types::{ConflictItem, ConflictItemRef, ResolverMarkerEnvironment}; use crate::pubgrub::{PubGrubDependency, PubGrubPackage}; -use crate::requires_python::RequiresPythonRange; use crate::resolver::ForkState; use crate::universal_marker::{ConflictMarker, UniversalMarker}; -use crate::{PythonRequirement, RequiresPython, ResolveError}; +use crate::{PythonRequirement, ResolveError}; /// Represents one or more marker environments for a resolution. /// @@ -628,7 +628,7 @@ mod tests { use uv_pep440::{LowerBound, UpperBound, Version}; use uv_pep508::{MarkerEnvironment, MarkerEnvironmentBuilder}; - use crate::requires_python::{RequiresPython, RequiresPythonRange}; + use uv_distribution_types::{RequiresPython, RequiresPythonRange}; use super::*; diff --git a/crates/uv-resolver/src/resolver/provider.rs b/crates/uv-resolver/src/resolver/provider.rs index 378d2a9eb..d6384e3e2 100644 --- a/crates/uv-resolver/src/resolver/provider.rs +++ b/crates/uv-resolver/src/resolver/provider.rs @@ -5,16 +5,17 @@ use uv_configuration::BuildOptions; use uv_distribution::{ArchiveMetadata, DistributionDatabase, Reporter}; use uv_distribution_types::{ Dist, IndexCapabilities, IndexMetadata, IndexMetadataRef, InstalledDist, RequestedDist, + RequiresPython, }; use uv_normalize::PackageName; use uv_pep440::{Version, VersionSpecifiers}; use uv_platform_tags::Tags; use uv_types::{BuildContext, HashStrategy}; +use crate::ExcludeNewer; use crate::flat_index::FlatIndex; use crate::version_map::VersionMap; use crate::yanks::AllowedYanks; -use crate::{ExcludeNewer, RequiresPython}; pub type PackageVersionsResult = Result; pub type WheelMetadataResult = Result; diff --git a/crates/uv-resolver/src/version_map.rs b/crates/uv-resolver/src/version_map.rs index 44e70e73b..8a0b17fc4 100644 --- a/crates/uv-resolver/src/version_map.rs +++ b/crates/uv-resolver/src/version_map.rs @@ -11,7 +11,8 @@ use uv_configuration::BuildOptions; use uv_distribution_filename::{DistFilename, WheelFilename}; use uv_distribution_types::{ HashComparison, IncompatibleSource, IncompatibleWheel, IndexUrl, PrioritizedDist, - RegistryBuiltWheel, RegistrySourceDist, SourceDistCompatibility, WheelCompatibility, + RegistryBuiltWheel, RegistrySourceDist, RequiresPython, SourceDistCompatibility, + WheelCompatibility, }; use uv_normalize::PackageName; use uv_pep440::Version; @@ -21,7 +22,7 @@ use uv_types::HashStrategy; use uv_warnings::warn_user_once; use crate::flat_index::FlatDistributions; -use crate::{ExcludeNewer, RequiresPython, yanks::AllowedYanks}; +use crate::{ExcludeNewer, yanks::AllowedYanks}; /// A map from versions to distributions. #[derive(Debug)] diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index ff6d39995..2c18fb40a 100644 --- a/crates/uv-settings/src/settings.rs +++ b/crates/uv-settings/src/settings.rs @@ -140,6 +140,9 @@ pub struct Options { #[cfg_attr(feature = "schemars", schemars(skip))] pub default_groups: Option, + #[cfg_attr(feature = "schemars", schemars(skip))] + pub dependency_groups: Option, + #[cfg_attr(feature = "schemars", schemars(skip))] pub managed: Option, @@ -1870,6 +1873,7 @@ pub struct OptionsWire { managed: Option, r#package: Option, default_groups: Option, + dependency_groups: Option, dev_dependencies: Option, // Build backend @@ -1934,6 +1938,7 @@ impl From for Options { workspace, sources, default_groups, + dependency_groups, dev_dependencies, managed, package, @@ -2010,6 +2015,7 @@ impl From for Options { sources, dev_dependencies, default_groups, + dependency_groups, managed, package, } diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index a8d672aab..36059f10f 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -18,6 +18,7 @@ workspace = true [dependencies] uv-build-backend = { workspace = true, features = ["schemars"] } uv-cache-key = { workspace = true } +uv-configuration = { workspace = true } uv-distribution-types = { workspace = true } uv-fs = { workspace = true, features = ["tokio", "schemars"] } uv-git-types = { workspace = true } diff --git a/crates/uv-workspace/src/dependency_groups.rs b/crates/uv-workspace/src/dependency_groups.rs index e6964544a..8503ae3ad 100644 --- a/crates/uv-workspace/src/dependency_groups.rs +++ b/crates/uv-workspace/src/dependency_groups.rs @@ -1,32 +1,106 @@ -use std::collections::BTreeMap; use std::collections::btree_map::Entry; use std::str::FromStr; +use std::{collections::BTreeMap, path::Path}; use thiserror::Error; use tracing::error; +use uv_distribution_types::RequiresPython; +use uv_fs::Simplified; use uv_normalize::{DEV_DEPENDENCIES, GroupName}; +use uv_pep440::VersionSpecifiers; use uv_pep508::Pep508Error; use uv_pypi_types::{DependencyGroupSpecifier, VerbatimParsedUrl}; +use crate::pyproject::{DependencyGroupSettings, PyProjectToml, ToolUvDependencyGroups}; + /// PEP 735 dependency groups, with any `include-group` entries resolved. #[derive(Debug, Default, Clone)] -pub struct FlatDependencyGroups( - BTreeMap>>, -); +pub struct FlatDependencyGroups(BTreeMap); + +#[derive(Debug, Default, Clone)] +pub struct FlatDependencyGroup { + pub requirements: Vec>, + pub requires_python: Option, +} impl FlatDependencyGroups { + /// Gather and flatten all the dependency-groups defined in the given pyproject.toml + /// + /// The path is only used in diagnostics. + pub fn from_pyproject_toml( + path: &Path, + pyproject_toml: &PyProjectToml, + ) -> Result { + // First, collect `tool.uv.dev_dependencies` + let dev_dependencies = pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.dev_dependencies.as_ref()); + + // Then, collect `dependency-groups` + let dependency_groups = pyproject_toml + .dependency_groups + .iter() + .flatten() + .collect::>(); + + // Get additional settings + let empty_settings = ToolUvDependencyGroups::default(); + let group_settings = pyproject_toml + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.dependency_groups.as_ref()) + .unwrap_or(&empty_settings); + + // Flatten the dependency groups. + let mut dependency_groups = FlatDependencyGroups::from_dependency_groups( + &dependency_groups, + group_settings.inner(), + ) + .map_err(|err| DependencyGroupError { + package: pyproject_toml + .project + .as_ref() + .map(|project| project.name.to_string()) + .unwrap_or_default(), + path: path.user_display().to_string(), + error: err.with_dev_dependencies(dev_dependencies), + })?; + + // Add the `dev` group, if the legacy `dev-dependencies` is defined. + // + // NOTE: the fact that we do this out here means that nothing can inherit from + // the legacy dev-dependencies group (or define a group requires-python for it). + // This is intentional, we want groups to be defined in a standard interoperable + // way, and letting things include-group a group that isn't defined would be a + // mess for other python tools. + if let Some(dev_dependencies) = dev_dependencies { + dependency_groups + .entry(DEV_DEPENDENCIES.clone()) + .or_insert_with(FlatDependencyGroup::default) + .requirements + .extend(dev_dependencies.clone()); + } + + Ok(dependency_groups) + } + /// Resolve the dependency groups (which may contain references to other groups) into concrete /// lists of requirements. - pub fn from_dependency_groups( + fn from_dependency_groups( groups: &BTreeMap<&GroupName, &Vec>, - ) -> Result { + settings: &BTreeMap, + ) -> Result { fn resolve_group<'data>( - resolved: &mut BTreeMap>>, + resolved: &mut BTreeMap, groups: &'data BTreeMap<&GroupName, &Vec>, + settings: &BTreeMap, name: &'data GroupName, parents: &mut Vec<&'data GroupName>, - ) -> Result<(), DependencyGroupError> { + ) -> Result<(), DependencyGroupErrorInner> { let Some(specifiers) = groups.get(name) else { // Missing group let parent_name = parents @@ -34,7 +108,7 @@ impl FlatDependencyGroups { .last() .copied() .expect("parent when group is missing"); - return Err(DependencyGroupError::GroupNotFound( + return Err(DependencyGroupErrorInner::GroupNotFound( name.clone(), parent_name.clone(), )); @@ -42,7 +116,7 @@ impl FlatDependencyGroups { // "Dependency Group Includes MUST NOT include cycles, and tools SHOULD report an error if they detect a cycle." if parents.contains(&name) { - return Err(DependencyGroupError::DependencyGroupCycle(Cycle( + return Err(DependencyGroupErrorInner::DependencyGroupCycle(Cycle( parents.iter().copied().cloned().collect(), ))); } @@ -54,13 +128,14 @@ impl FlatDependencyGroups { parents.push(name); let mut requirements = Vec::with_capacity(specifiers.len()); + let mut requires_python_intersection = VersionSpecifiers::empty(); for specifier in *specifiers { match specifier { DependencyGroupSpecifier::Requirement(requirement) => { match uv_pep508::Requirement::::from_str(requirement) { Ok(requirement) => requirements.push(requirement), Err(err) => { - return Err(DependencyGroupError::GroupParseError( + return Err(DependencyGroupErrorInner::GroupParseError( name.clone(), requirement.clone(), Box::new(err), @@ -69,72 +144,107 @@ impl FlatDependencyGroups { } } DependencyGroupSpecifier::IncludeGroup { include_group } => { - resolve_group(resolved, groups, include_group, parents)?; - requirements - .extend(resolved.get(include_group).into_iter().flatten().cloned()); + resolve_group(resolved, groups, settings, include_group, parents)?; + if let Some(included) = resolved.get(include_group) { + requirements.extend(included.requirements.iter().cloned()); + + // Intersect the requires-python for this group with the the included group's + requires_python_intersection = requires_python_intersection + .into_iter() + .chain(included.requires_python.clone().into_iter().flatten()) + .collect(); + } } DependencyGroupSpecifier::Object(map) => { - return Err(DependencyGroupError::DependencyObjectSpecifierNotSupported( - name.clone(), - map.clone(), - )); + return Err( + DependencyGroupErrorInner::DependencyObjectSpecifierNotSupported( + name.clone(), + map.clone(), + ), + ); } } } + + let empty_settings = DependencyGroupSettings::default(); + let DependencyGroupSettings { requires_python } = + settings.get(name).unwrap_or(&empty_settings); + if let Some(requires_python) = requires_python { + // Intersect the requires-python for this group to get the final requires-python + // that will be used by interpreter discovery and checking. + requires_python_intersection = requires_python_intersection + .into_iter() + .chain(requires_python.clone()) + .collect(); + + // Add the group requires-python as a marker to each requirement + // We don't use `requires_python_intersection` because each `include-group` + // should already have its markers applied to these. + for requirement in &mut requirements { + let extra_markers = + RequiresPython::from_specifiers(requires_python).to_marker_tree(); + requirement.marker.and(extra_markers); + } + } + parents.pop(); - resolved.insert(name.clone(), requirements); + resolved.insert( + name.clone(), + FlatDependencyGroup { + requirements, + requires_python: if requires_python_intersection.is_empty() { + None + } else { + Some(requires_python_intersection) + }, + }, + ); Ok(()) } + // Validate the settings + for (group_name, ..) in settings { + if !groups.contains_key(group_name) { + return Err(DependencyGroupErrorInner::SettingsGroupNotFound( + group_name.clone(), + )); + } + } + let mut resolved = BTreeMap::new(); for name in groups.keys() { let mut parents = Vec::new(); - resolve_group(&mut resolved, groups, name, &mut parents)?; + resolve_group(&mut resolved, groups, settings, name, &mut parents)?; } Ok(Self(resolved)) } /// Return the requirements for a given group, if any. - pub fn get( - &self, - group: &GroupName, - ) -> Option<&Vec>> { + pub fn get(&self, group: &GroupName) -> Option<&FlatDependencyGroup> { self.0.get(group) } /// Return the entry for a given group, if any. - pub fn entry( - &mut self, - group: GroupName, - ) -> Entry>> { + pub fn entry(&mut self, group: GroupName) -> Entry { self.0.entry(group) } /// Consume the [`FlatDependencyGroups`] and return the inner map. - pub fn into_inner(self) -> BTreeMap>> { + pub fn into_inner(self) -> BTreeMap { self.0 } } -impl FromIterator<(GroupName, Vec>)> - for FlatDependencyGroups -{ - fn from_iter< - T: IntoIterator>)>, - >( - iter: T, - ) -> Self { +impl FromIterator<(GroupName, FlatDependencyGroup)> for FlatDependencyGroups { + fn from_iter>(iter: T) -> Self { Self(iter.into_iter().collect()) } } impl IntoIterator for FlatDependencyGroups { - type Item = (GroupName, Vec>); - type IntoIter = std::collections::btree_map::IntoIter< - GroupName, - Vec>, - >; + type Item = (GroupName, FlatDependencyGroup); + type IntoIter = std::collections::btree_map::IntoIter; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() @@ -142,7 +252,24 @@ impl IntoIterator for FlatDependencyGroups { } #[derive(Debug, Error)] -pub enum DependencyGroupError { +#[error("{} has malformed dependency groups", if path.is_empty() && package.is_empty() { + "Project".to_string() +} else if path.is_empty() { + format!("Project `{package}`") +} else if package.is_empty() { + format!("`{path}`") +} else { + format!("Project `{package} @ {path}`") +})] +pub struct DependencyGroupError { + package: String, + path: String, + #[source] + error: DependencyGroupErrorInner, +} + +#[derive(Debug, Error)] +pub enum DependencyGroupErrorInner { #[error("Failed to parse entry in group `{0}`: `{1}`")] GroupParseError( GroupName, @@ -159,9 +286,15 @@ pub enum DependencyGroupError { DependencyGroupCycle(Cycle), #[error("Group `{0}` contains an unknown dependency object specifier: {1:?}")] DependencyObjectSpecifierNotSupported(GroupName, BTreeMap), + #[error("Failed to find group `{0}` specified in `[tool.uv.dependency-groups]`")] + SettingsGroupNotFound(GroupName), + #[error( + "`[tool.uv.dependency-groups]` specifies the `dev` group, but only `tool.uv.dev-dependencies` was found. To reference the `dev` group, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead." + )] + SettingsDevGroupInclude, } -impl DependencyGroupError { +impl DependencyGroupErrorInner { /// Enrich a [`DependencyGroupError`] with the `tool.uv.dev-dependencies` metadata, if applicable. #[must_use] pub fn with_dev_dependencies( @@ -169,10 +302,15 @@ impl DependencyGroupError { dev_dependencies: Option<&Vec>>, ) -> Self { match self { - DependencyGroupError::GroupNotFound(group, parent) + Self::GroupNotFound(group, parent) if dev_dependencies.is_some() && group == *DEV_DEPENDENCIES => { - DependencyGroupError::DevGroupInclude(parent) + Self::DevGroupInclude(parent) + } + Self::SettingsGroupNotFound(group) + if dev_dependencies.is_some() && group == *DEV_DEPENDENCIES => + { + Self::SettingsDevGroupInclude } _ => self, } diff --git a/crates/uv-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs index 83be6bd88..0e1b3974c 100644 --- a/crates/uv-workspace/src/lib.rs +++ b/crates/uv-workspace/src/lib.rs @@ -1,6 +1,6 @@ pub use workspace::{ - DiscoveryOptions, MemberDiscovery, ProjectWorkspace, VirtualProject, Workspace, WorkspaceCache, - WorkspaceError, WorkspaceMember, + DiscoveryOptions, MemberDiscovery, ProjectWorkspace, RequiresPythonSources, VirtualProject, + Workspace, WorkspaceCache, WorkspaceError, WorkspaceMember, }; pub mod dependency_groups; diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 2b0e44c16..6499aad5d 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -353,6 +353,24 @@ pub struct ToolUv { )] pub default_groups: Option, + /// Additional settings for `dependency-groups`. + /// + /// Currently this can only be used to add `requires-python` constraints + /// to dependency groups (typically to inform uv that your dev tooling + /// has a higher python requirement than your actual project). + /// + /// This cannot be used to define dependency groups, use the top-level + /// `[dependency-groups]` table for that. + #[option( + default = "[]", + value_type = "dict", + example = r#" + [tool.uv.dependency-groups] + my-group = {requires-python = ">=3.12"} + "# + )] + pub dependency_groups: Option, + /// The project's development dependencies. /// /// Development dependencies will be installed by default in `uv run` and `uv sync`, but will @@ -653,6 +671,77 @@ impl<'de> serde::de::Deserialize<'de> for ToolUvSources { } } +#[derive(Default, Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(Serialize))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub struct ToolUvDependencyGroups(BTreeMap); + +impl ToolUvDependencyGroups { + /// Returns the underlying `BTreeMap` of group names to settings. + pub fn inner(&self) -> &BTreeMap { + &self.0 + } + + /// Convert the [`ToolUvDependencyGroups`] into its inner `BTreeMap`. + #[must_use] + pub fn into_inner(self) -> BTreeMap { + self.0 + } +} + +/// Ensure that all keys in the TOML table are unique. +impl<'de> serde::de::Deserialize<'de> for ToolUvDependencyGroups { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct SourcesVisitor; + + impl<'de> serde::de::Visitor<'de> for SourcesVisitor { + type Value = ToolUvDependencyGroups; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a map with unique keys") + } + + fn visit_map(self, mut access: M) -> Result + where + M: serde::de::MapAccess<'de>, + { + let mut groups = BTreeMap::new(); + while let Some((key, value)) = + access.next_entry::()? + { + match groups.entry(key) { + std::collections::btree_map::Entry::Occupied(entry) => { + return Err(serde::de::Error::custom(format!( + "duplicate settings for dependency group `{}`", + entry.key() + ))); + } + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(value); + } + } + } + Ok(ToolUvDependencyGroups(groups)) + } + } + + deserializer.deserialize_map(SourcesVisitor) + } +} + +#[derive(Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(Serialize))] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub struct DependencyGroupSettings { + /// Version of python to require when installing this group + #[cfg_attr(feature = "schemars", schemars(with = "Option"))] + pub requires_python: Option, +} + #[derive(Deserialize, OptionsMetadata, Default, Debug, Clone, PartialEq, Eq)] #[cfg_attr(test, derive(Serialize))] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 3caaa8f8c..ed99d3d30 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -8,6 +8,7 @@ use glob::{GlobError, PatternError, glob}; use rustc_hash::{FxHashMap, FxHashSet}; use tracing::{debug, trace, warn}; +use uv_configuration::DependencyGroupsWithDefaults; use uv_distribution_types::{Index, Requirement, RequirementSource}; use uv_fs::{CWD, Simplified}; use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName}; @@ -17,7 +18,7 @@ use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl}; use uv_static::EnvVars; use uv_warnings::warn_user_once; -use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroups}; +use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups}; use crate::pyproject::{ Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources, ToolUvWorkspace, }; @@ -95,6 +96,8 @@ pub struct DiscoveryOptions { pub members: MemberDiscovery, } +pub type RequiresPythonSources = BTreeMap<(PackageName, Option), VersionSpecifiers>; + /// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`]. #[derive(Debug, Clone)] #[cfg_attr(test, derive(serde::Serialize))] @@ -413,15 +416,44 @@ impl Workspace { } /// Returns an iterator over the `requires-python` values for each member of the workspace. - pub fn requires_python(&self) -> impl Iterator { - self.packages().iter().filter_map(|(name, member)| { - member + pub fn requires_python( + &self, + groups: &DependencyGroupsWithDefaults, + ) -> Result { + let mut requires = RequiresPythonSources::new(); + for (name, member) in self.packages() { + // Get the top-level requires-python for this package, which is always active + // + // Arguably we could check groups.prod() to disable this, since, the requires-python + // of the project is *technically* not relevant if you're doing `--only-group`, but, + // that would be a big surprising change so let's *not* do that until someone asks! + let top_requires = member .pyproject_toml() .project .as_ref() .and_then(|project| project.requires_python.as_ref()) - .map(|requires_python| (name, requires_python)) - }) + .map(|requires_python| ((name.to_owned(), None), requires_python.clone())); + requires.extend(top_requires); + + // Get the requires-python for each enabled group on this package + // We need to do full flattening here because include-group can transfer requires-python + let dependency_groups = + FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?; + let group_requires = + dependency_groups + .into_iter() + .filter_map(move |(group_name, flat_group)| { + if groups.contains(&group_name) { + flat_group.requires_python.map(|requires_python| { + ((name.to_owned(), Some(group_name)), requires_python) + }) + } else { + None + } + }); + requires.extend(group_requires); + } + Ok(requires) } /// Returns any requirements that are exclusive to the workspace root, i.e., not included in @@ -439,12 +471,9 @@ impl Workspace { /// corresponding `pyproject.toml`. /// /// Otherwise, returns an empty list. - pub fn dependency_groups( + pub fn workspace_dependency_groups( &self, - ) -> Result< - BTreeMap>>, - DependencyGroupError, - > { + ) -> Result, DependencyGroupError> { if self .packages .values() @@ -455,35 +484,10 @@ impl Workspace { Ok(BTreeMap::default()) } else { // Otherwise, return the dependency groups in the non-project workspace root. - // First, collect `tool.uv.dev_dependencies` - let dev_dependencies = self - .pyproject_toml - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.dev_dependencies.as_ref()); - - // Then, collect `dependency-groups` - let dependency_groups = self - .pyproject_toml - .dependency_groups - .iter() - .flatten() - .collect::>(); - - // Flatten the dependency groups. - let mut dependency_groups = - FlatDependencyGroups::from_dependency_groups(&dependency_groups) - .map_err(|err| err.with_dev_dependencies(dev_dependencies))?; - - // Add the `dev` group, if `dev-dependencies` is defined. - if let Some(dev_dependencies) = dev_dependencies { - dependency_groups - .entry(DEV_DEPENDENCIES.clone()) - .or_insert_with(Vec::new) - .extend(dev_dependencies.clone()); - } - + let dependency_groups = FlatDependencyGroups::from_pyproject_toml( + &self.install_path, + &self.pyproject_toml, + )?; Ok(dependency_groups.into_inner()) } } @@ -1818,6 +1822,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -1913,6 +1918,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2123,6 +2129,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2230,6 +2237,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2350,6 +2358,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2444,6 +2453,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index c601541da..dd174ca06 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -16,13 +16,16 @@ use uv_cache::{Cache, CacheBucket}; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ BuildKind, BuildOptions, BuildOutput, Concurrency, ConfigSettings, Constraints, - HashCheckingMode, IndexStrategy, KeyringProviderType, PreviewMode, SourceStrategy, + DependencyGroupsWithDefaults, HashCheckingMode, IndexStrategy, KeyringProviderType, + PreviewMode, SourceStrategy, }; use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution_filename::{ DistFilename, SourceDistExtension, SourceDistFilename, WheelFilename, }; -use uv_distribution_types::{DependencyMetadata, Index, IndexLocations, SourceDist}; +use uv_distribution_types::{ + DependencyMetadata, Index, IndexLocations, RequiresPython, SourceDist, +}; use uv_fs::{Simplified, relative_to}; use uv_install_wheel::LinkMode; use uv_normalize::PackageName; @@ -33,7 +36,7 @@ use uv_python::{ VersionRequest, }; use uv_requirements::RequirementsSource; -use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython}; +use uv_resolver::{ExcludeNewer, FlatIndex}; use uv_settings::PythonInstallMirrors; use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy}; use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceError}; @@ -471,7 +474,8 @@ async fn build_package( // (3) `Requires-Python` in `pyproject.toml` if interpreter_request.is_none() { if let Ok(workspace) = workspace { - interpreter_request = find_requires_python(workspace)? + let groups = DependencyGroupsWithDefaults::none(); + interpreter_request = find_requires_python(workspace, &groups)? .as_ref() .map(RequiresPython::specifiers) .map(|specifiers| { diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index 8da16ef46..20a60416f 100644 --- a/crates/uv/src/commands/pip/compile.rs +++ b/crates/uv/src/commands/pip/compile.rs @@ -21,7 +21,7 @@ use uv_configuration::{KeyringProviderType, TargetTriple}; use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution_types::{ DependencyMetadata, HashGeneration, Index, IndexLocations, NameRequirementSpecification, - Origin, Requirement, UnresolvedRequirementSpecification, Verbatim, + Origin, Requirement, RequiresPython, UnresolvedRequirementSpecification, Verbatim, }; use uv_fs::{CWD, Simplified}; use uv_git::ResolvedRepositoryReference; @@ -38,8 +38,8 @@ use uv_requirements::{ }; use uv_resolver::{ AnnotationStyle, DependencyMode, DisplayResolutionGraph, ExcludeNewer, FlatIndex, ForkStrategy, - InMemoryIndex, OptionsBuilder, PrereleaseMode, PylockToml, PythonRequirement, RequiresPython, - ResolutionMode, ResolverEnvironment, + InMemoryIndex, OptionsBuilder, PrereleaseMode, PylockToml, PythonRequirement, ResolutionMode, + ResolverEnvironment, }; use uv_torch::{TorchMode, TorchStrategy}; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; diff --git a/crates/uv/src/commands/pip/latest.rs b/crates/uv/src/commands/pip/latest.rs index ac3ce7d1f..25da8466c 100644 --- a/crates/uv/src/commands/pip/latest.rs +++ b/crates/uv/src/commands/pip/latest.rs @@ -3,10 +3,10 @@ use tracing::debug; use uv_client::{MetadataFormat, RegistryClient, VersionFiles}; use uv_distribution_filename::DistFilename; -use uv_distribution_types::{IndexCapabilities, IndexMetadataRef, IndexUrl}; +use uv_distribution_types::{IndexCapabilities, IndexMetadataRef, IndexUrl, RequiresPython}; use uv_normalize::PackageName; use uv_platform_tags::Tags; -use uv_resolver::{ExcludeNewer, PrereleaseMode, RequiresPython}; +use uv_resolver::{ExcludeNewer, PrereleaseMode}; use uv_warnings::warn_user_once; /// A client to fetch the latest version of a package from an index. diff --git a/crates/uv/src/commands/pip/list.rs b/crates/uv/src/commands/pip/list.rs index 48786d86c..824c9db2b 100644 --- a/crates/uv/src/commands/pip/list.rs +++ b/crates/uv/src/commands/pip/list.rs @@ -17,14 +17,16 @@ use uv_cli::ListFormat; use uv_client::{BaseClientBuilder, RegistryClientBuilder}; use uv_configuration::{Concurrency, IndexStrategy, KeyringProviderType}; use uv_distribution_filename::DistFilename; -use uv_distribution_types::{Diagnostic, IndexCapabilities, IndexLocations, InstalledDist, Name}; +use uv_distribution_types::{ + Diagnostic, IndexCapabilities, IndexLocations, InstalledDist, Name, RequiresPython, +}; use uv_fs::Simplified; use uv_installer::SitePackages; use uv_normalize::PackageName; use uv_pep440::Version; use uv_python::PythonRequest; use uv_python::{EnvironmentPreference, PythonEnvironment}; -use uv_resolver::{ExcludeNewer, PrereleaseMode, RequiresPython}; +use uv_resolver::{ExcludeNewer, PrereleaseMode}; use crate::commands::ExitStatus; use crate::commands::pip::latest::LatestClient; diff --git a/crates/uv/src/commands/pip/tree.rs b/crates/uv/src/commands/pip/tree.rs index 05290ffd0..aed364068 100644 --- a/crates/uv/src/commands/pip/tree.rs +++ b/crates/uv/src/commands/pip/tree.rs @@ -14,14 +14,14 @@ use uv_cache::{Cache, Refresh}; use uv_cache_info::Timestamp; use uv_client::{BaseClientBuilder, RegistryClientBuilder}; use uv_configuration::{Concurrency, IndexStrategy, KeyringProviderType}; -use uv_distribution_types::{Diagnostic, IndexCapabilities, IndexLocations, Name}; +use uv_distribution_types::{Diagnostic, IndexCapabilities, IndexLocations, Name, RequiresPython}; use uv_installer::SitePackages; use uv_normalize::PackageName; use uv_pep440::Version; use uv_pep508::{Requirement, VersionOrUrl}; use uv_pypi_types::{ResolutionMetadata, ResolverMarkerEnvironment, VerbatimParsedUrl}; use uv_python::{EnvironmentPreference, PythonEnvironment, PythonRequest}; -use uv_resolver::{ExcludeNewer, PrereleaseMode, RequiresPython}; +use uv_resolver::{ExcludeNewer, PrereleaseMode}; use crate::commands::ExitStatus; use crate::commands::pip::latest::LatestClient; diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index a4091504d..8e3c4a03a 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -17,8 +17,9 @@ use uv_cache::Cache; use uv_cache_key::RepositoryUrl; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, DependencyGroups, DevMode, DryRun, EditableMode, ExtrasSpecification, - InstallOptions, PreviewMode, SourceStrategy, + Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DevMode, DryRun, + EditableMode, ExtrasSpecification, ExtrasSpecificationWithDefaults, InstallOptions, + PreviewMode, SourceStrategy, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -29,7 +30,7 @@ use uv_distribution_types::{ use uv_fs::{LockedFile, Simplified}; use uv_git::GIT_STORE; use uv_git_types::GitReference; -use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras, PackageName}; +use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras, DefaultGroups, PackageName}; use uv_pep508::{ExtraName, MarkerTree, UnnamedRequirement, VersionOrUrl}; use uv_pypi_types::{ParsedUrl, VerbatimParsedUrl}; use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreference, PythonRequest}; @@ -79,7 +80,7 @@ pub(crate) async fn add( rev: Option, tag: Option, branch: Option, - extras: Vec, + extras_of_dependency: Vec, package: Option, python: Option, install_mirrors: PythonInstallMirrors, @@ -122,6 +123,34 @@ pub(crate) async fn add( let reporter = PythonDownloadReporter::single(printer); + // Determine what defaults/extras we're explicitly enabling + let (extras, groups) = match &dependency_type { + DependencyType::Production => { + let extras = ExtrasSpecification::from_extra(vec![]); + let groups = DependencyGroups::from_dev_mode(DevMode::Exclude); + (extras, groups) + } + DependencyType::Dev => { + let extras = ExtrasSpecification::from_extra(vec![]); + let groups = DependencyGroups::from_dev_mode(DevMode::Include); + (extras, groups) + } + DependencyType::Optional(extra_name) => { + let extras = ExtrasSpecification::from_extra(vec![extra_name.clone()]); + let groups = DependencyGroups::from_dev_mode(DevMode::Exclude); + (extras, groups) + } + DependencyType::Group(group_name) => { + let extras = ExtrasSpecification::from_extra(vec![]); + let groups = DependencyGroups::from_group(group_name.clone()); + (extras, groups) + } + }; + // Default extras currently always disabled + let defaulted_extras = extras.with_defaults(DefaultExtras::default()); + // Default groups we need the actual project for, interpreter discovery will use this! + let defaulted_groups; + let target = if let Some(script) = script { // If we found a PEP 723 script and the user provided a project-only setting, warn. if package.is_some() { @@ -172,6 +201,9 @@ pub(crate) async fn add( } }; + // Scripts don't actually have groups + defaulted_groups = groups.with_defaults(DefaultGroups::default()); + // Discover the interpreter. let interpreter = ScriptInterpreter::discover( Pep723ItemRef::Script(&script), @@ -234,11 +266,16 @@ pub(crate) async fn add( } } + // Enable the default groups of the project + defaulted_groups = + groups.with_defaults(default_dependency_groups(project.pyproject_toml())?); + if frozen || no_sync { // Discover the interpreter. let interpreter = ProjectInterpreter::discover( project.workspace(), project_dir, + &defaulted_groups, python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, @@ -258,6 +295,7 @@ pub(crate) async fn add( // Discover or create the virtual environment. let environment = ProjectEnvironment::get_or_init( project.workspace(), + &defaulted_groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, &network_settings, @@ -468,7 +506,7 @@ pub(crate) async fn add( rev.as_deref(), tag.as_deref(), branch.as_deref(), - &extras, + &extras_of_dependency, index, &mut toml, )?; @@ -551,7 +589,8 @@ pub(crate) async fn add( lock_state, sync_state, locked, - &dependency_type, + &defaulted_extras, + &defaulted_groups, raw, bounds, constraints, @@ -778,7 +817,8 @@ async fn lock_and_sync( lock_state: UniversalState, sync_state: PlatformState, locked: bool, - dependency_type: &DependencyType, + extras: &ExtrasSpecificationWithDefaults, + groups: &DependencyGroupsWithDefaults, raw: bool, bound_kind: Option, constraints: Vec, @@ -942,36 +982,6 @@ async fn lock_and_sync( return Ok(()); }; - // Sync the environment. - let (extras, dev) = match dependency_type { - DependencyType::Production => { - let extras = ExtrasSpecification::from_extra(vec![]); - let dev = DependencyGroups::from_dev_mode(DevMode::Exclude); - (extras, dev) - } - DependencyType::Dev => { - let extras = ExtrasSpecification::from_extra(vec![]); - let dev = DependencyGroups::from_dev_mode(DevMode::Include); - (extras, dev) - } - DependencyType::Optional(extra_name) => { - let extras = ExtrasSpecification::from_extra(vec![extra_name.clone()]); - let dev = DependencyGroups::from_dev_mode(DevMode::Exclude); - (extras, dev) - } - DependencyType::Group(group_name) => { - let extras = ExtrasSpecification::from_extra(vec![]); - let dev = DependencyGroups::from_group(group_name.clone()); - (extras, dev) - } - }; - - // Determine the default groups to include. - let default_groups = default_dependency_groups(project.pyproject_toml())?; - - // Determine the default extras to include. - let default_extras = DefaultExtras::default(); - // Identify the installation target. let target = match &project { VirtualProject::Project(project) => InstallTarget::Project { @@ -988,8 +998,8 @@ async fn lock_and_sync( project::sync::do_sync( target, venv, - &extras.with_defaults(default_extras), - &dev.with_defaults(default_groups), + extras, + groups, EditableMode::Editable, InstallOptions::default(), Modifications::Sufficient, diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 566f4af41..ac228989c 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -61,7 +61,7 @@ pub(crate) async fn export( install_options: InstallOptions, output_file: Option, extras: ExtrasSpecification, - dev: DependencyGroups, + groups: DependencyGroups, editable: EditableMode, locked: bool, frozen: bool, @@ -122,7 +122,7 @@ pub(crate) async fn export( ExportTarget::Script(_) => DefaultExtras::default(), }; - let dev = dev.with_defaults(default_groups); + let groups = groups.with_defaults(default_groups); let extras = extras.with_defaults(default_extras); // Find an interpreter for the project, unless `--frozen` is set. @@ -148,6 +148,7 @@ pub(crate) async fn export( ExportTarget::Project(project) => ProjectInterpreter::discover( project.workspace(), project_dir, + &groups, python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, @@ -206,7 +207,7 @@ pub(crate) async fn export( }; // Validate that the set of requested extras and development groups are compatible. - detect_conflicts(&lock, &extras, &dev)?; + detect_conflicts(&lock, &extras, &groups)?; // Identify the installation target. let target = match &target { @@ -259,7 +260,7 @@ pub(crate) async fn export( // Validate that the set of requested extras and development groups are defined in the lockfile. target.validate_extras(&extras)?; - target.validate_groups(&dev)?; + target.validate_groups(&groups)?; // Write the resolved dependencies to the output channel. let mut writer = OutputWriter::new(!quiet || output_file.is_none(), output_file.as_deref()); @@ -306,7 +307,7 @@ pub(crate) async fn export( &target, &prune, &extras, - &dev, + &groups, include_annotations, editable, hashes, @@ -328,7 +329,7 @@ pub(crate) async fn export( &target, &prune, &extras, - &dev, + &groups, include_annotations, editable, &install_options, diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 376e8e007..71aacdc1b 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -4,13 +4,15 @@ use std::fmt::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::str::FromStr; +use uv_distribution_types::RequiresPython; use tracing::{debug, trace, warn}; use uv_cache::Cache; use uv_cli::AuthorFrom; use uv_client::BaseClientBuilder; use uv_configuration::{ - PreviewMode, ProjectBuildBackend, VersionControlError, VersionControlSystem, + DependencyGroupsWithDefaults, PreviewMode, ProjectBuildBackend, VersionControlError, + VersionControlSystem, }; use uv_fs::{CWD, Simplified}; use uv_git::GIT; @@ -21,7 +23,6 @@ use uv_python::{ PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest, }; -use uv_resolver::RequiresPython; use uv_scripts::{Pep723Script, ScriptTag}; use uv_settings::PythonInstallMirrors; use uv_static::EnvVars; @@ -502,7 +503,7 @@ async fn init_project( (requires_python, python_request) } else if let Some(requires_python) = workspace .as_ref() - .map(find_requires_python) + .map(|workspace| find_requires_python(workspace, &DependencyGroupsWithDefaults::none())) .transpose()? .flatten() { diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index d225114c9..b0f20e76f 100644 --- a/crates/uv/src/commands/project/install_target.rs +++ b/crates/uv/src/commands/project/install_target.rs @@ -165,11 +165,18 @@ impl<'lock> InstallTarget<'lock> { .requirements() .into_iter() .map(Cow::Owned) - .chain(workspace.dependency_groups().ok().into_iter().flat_map( - |dependency_groups| { - dependency_groups.into_values().flatten().map(Cow::Owned) - }, - )) + .chain( + workspace + .workspace_dependency_groups() + .ok() + .into_iter() + .flat_map(|dependency_groups| { + dependency_groups + .into_values() + .flat_map(|group| group.requirements) + .map(Cow::Owned) + }), + ) .chain(workspace.packages().values().flat_map(|member| { // Iterate over all dependencies in each member. let dependencies = member @@ -316,9 +323,15 @@ impl<'lock> InstallTarget<'lock> { let known_groups = member_packages .iter() .flat_map(|package| package.dependency_groups().keys().map(Cow::Borrowed)) - .chain(workspace.dependency_groups().ok().into_iter().flat_map( - |dependency_groups| dependency_groups.into_keys().map(Cow::Owned), - )) + .chain( + workspace + .workspace_dependency_groups() + .ok() + .into_iter() + .flat_map(|dependency_groups| { + dependency_groups.into_keys().map(Cow::Owned) + }), + ) .collect::>(); for group in groups.explicit_names() { diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 89b3713cc..b57df429b 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -12,13 +12,14 @@ use tracing::debug; use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, DryRun, ExtrasSpecification, PreviewMode, Reinstall, Upgrade, + Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, + PreviewMode, Reinstall, Upgrade, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; use uv_distribution_types::{ DependencyMetadata, HashGeneration, Index, IndexLocations, NameRequirementSpecification, - Requirement, UnresolvedRequirementSpecification, + Requirement, RequiresPython, UnresolvedRequirementSpecification, }; use uv_git::ResolvedRepositoryReference; use uv_normalize::{GroupName, PackageName}; @@ -28,7 +29,7 @@ use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreferenc use uv_requirements::ExtrasResolver; use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_resolver::{ - FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython, + FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker, }; use uv_scripts::{Pep723ItemRef, Pep723Script}; @@ -142,6 +143,8 @@ pub(crate) async fn lock( LockTarget::Workspace(workspace) => ProjectInterpreter::discover( workspace, project_dir, + // Don't enable any groups' requires-python for interpreter discovery + &DependencyGroupsWithDefaults::none(), python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, @@ -437,8 +440,8 @@ async fn do_lock( let build_constraints = target.lower(build_constraints, index_locations, *sources)?; let dependency_groups = dependency_groups .into_iter() - .map(|(name, requirements)| { - let requirements = target.lower(requirements, index_locations, *sources)?; + .map(|(name, group)| { + let requirements = target.lower(group.requirements, index_locations, *sources)?; Ok((name, requirements)) }) .collect::, ProjectError>>()?; diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index cb45aa8ec..4618b3b84 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -3,15 +3,15 @@ use std::path::{Path, PathBuf}; use itertools::Either; -use uv_configuration::SourceStrategy; +use uv_configuration::{DependencyGroupsWithDefaults, SourceStrategy}; use uv_distribution::LoweredRequirement; -use uv_distribution_types::{Index, IndexLocations, Requirement}; +use uv_distribution_types::{Index, IndexLocations, Requirement, RequiresPython}; use uv_normalize::{GroupName, PackageName}; use uv_pep508::RequirementOrigin; use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl}; -use uv_resolver::{Lock, LockVersion, RequiresPython, VERSION}; +use uv_resolver::{Lock, LockVersion, VERSION}; use uv_scripts::Pep723Script; -use uv_workspace::dependency_groups::DependencyGroupError; +use uv_workspace::dependency_groups::{DependencyGroupError, FlatDependencyGroup}; use uv_workspace::{Workspace, WorkspaceMember}; use crate::commands::project::{ProjectError, find_requires_python}; @@ -100,12 +100,9 @@ impl<'lock> LockTarget<'lock> { /// attached to any members within the target. pub(crate) fn dependency_groups( self, - ) -> Result< - BTreeMap>>, - DependencyGroupError, - > { + ) -> Result, DependencyGroupError> { match self { - Self::Workspace(workspace) => workspace.dependency_groups(), + Self::Workspace(workspace) => workspace.workspace_dependency_groups(), Self::Script(_) => Ok(BTreeMap::new()), } } @@ -219,7 +216,11 @@ impl<'lock> LockTarget<'lock> { #[allow(clippy::result_large_err)] pub(crate) fn requires_python(self) -> Result, ProjectError> { match self { - Self::Workspace(workspace) => find_requires_python(workspace), + Self::Workspace(workspace) => { + // When locking, don't try to enforce requires-python bounds that appear on groups + let groups = DependencyGroupsWithDefaults::none(); + find_requires_python(workspace, &groups) + } Self::Script(script) => Ok(script .metadata .requires_python diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index d2efc3ccd..85defd4dd 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -18,7 +18,8 @@ use uv_configuration::{ use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution::{DistributionDatabase, LoweredRequirement}; use uv_distribution_types::{ - Index, Requirement, Resolution, UnresolvedRequirement, UnresolvedRequirementSpecification, + Index, Requirement, RequiresPython, Resolution, UnresolvedRequirement, + UnresolvedRequirementSpecification, }; use uv_fs::{CWD, LockedFile, Simplified}; use uv_git::ResolvedRepositoryReference; @@ -35,8 +36,8 @@ use uv_python::{ use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; use uv_resolver::{ - FlatIndex, Lock, OptionsBuilder, Preference, PythonRequirement, RequiresPython, - ResolverEnvironment, ResolverOutput, + FlatIndex, Lock, OptionsBuilder, Preference, PythonRequirement, ResolverEnvironment, + ResolverOutput, }; use uv_scripts::Pep723ItemRef; use uv_settings::PythonInstallMirrors; @@ -45,7 +46,7 @@ use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::dependency_groups::DependencyGroupError; use uv_workspace::pyproject::PyProjectToml; -use uv_workspace::{Workspace, WorkspaceCache}; +use uv_workspace::{RequiresPythonSources, Workspace, WorkspaceCache}; use crate::commands::pip::loggers::{InstallLogger, ResolveLogger}; use crate::commands::pip::operations::{Changelog, Modifications}; @@ -108,19 +109,28 @@ pub(crate) enum ProjectError { Conflict(#[from] ConflictError), #[error( - "The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`" + "The requested interpreter resolved to Python {_0}, which is incompatible with the project's Python requirement: `{_1}`{}", + format_optional_requires_python_sources(_2, *_3) )] - RequestedPythonProjectIncompatibility(Version, RequiresPython), + RequestedPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources, bool), #[error( - "The Python request from `{0}` resolved to Python {1}, which is incompatible with the project's Python requirement: `{2}`. Use `uv python pin` to update the `.python-version` file to a compatible version." + "The Python request from `{_0}` resolved to Python {_1}, which is incompatible with the project's Python requirement: `{_2}`{}\nUse `uv python pin` to update the `.python-version` file to a compatible version", + format_optional_requires_python_sources(_3, *_4) )] - DotPythonVersionProjectIncompatibility(String, Version, RequiresPython), + DotPythonVersionProjectIncompatibility( + String, + Version, + RequiresPython, + RequiresPythonSources, + bool, + ), #[error( - "The resolved Python interpreter (Python {0}) is incompatible with the project's Python requirement: `{1}`" + "The resolved Python interpreter (Python {_0}) is incompatible with the project's Python requirement: `{_1}`{}", + format_optional_requires_python_sources(_2, *_3) )] - RequiresPythonProjectIncompatibility(Version, RequiresPython), + RequiresPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources, bool), #[error( "The requested interpreter resolved to Python {0}, which is incompatible with the script's Python requirement: `{1}`" @@ -137,34 +147,6 @@ pub(crate) enum ProjectError { )] RequiresPythonScriptIncompatibility(Version, RequiresPython), - #[error("The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`. However, a workspace member (`{member}`) supports Python {3}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _2.cyan(), venv = format!("uv venv --python {_0}").green(), install = "uv pip install -e .".green(), path = _4.user_display().cyan() )] - RequestedMemberIncompatibility( - Version, - RequiresPython, - PackageName, - VersionSpecifiers, - PathBuf, - ), - - #[error("The Python request from `{0}` resolved to Python {1}, which is incompatible with the project's Python requirement: `{2}`. However, a workspace member (`{member}`) supports Python {4}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _3.cyan(), venv = format!("uv venv --python {_1}").green(), install = "uv pip install -e .".green(), path = _5.user_display().cyan() )] - DotPythonVersionMemberIncompatibility( - String, - Version, - RequiresPython, - PackageName, - VersionSpecifiers, - PathBuf, - ), - - #[error("The resolved Python interpreter (Python {0}) is incompatible with the project's Python requirement: `{1}`. However, a workspace member (`{member}`) supports Python {3}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _2.cyan(), venv = format!("uv venv --python {_0}").green(), install = "uv pip install -e .".green(), path = _4.user_display().cyan() )] - RequiresPythonMemberIncompatibility( - Version, - RequiresPython, - PackageName, - VersionSpecifiers, - PathBuf, - ), - #[error("Group `{0}` is not defined in the project's `dependency-groups` table")] MissingGroupProject(GroupName), @@ -194,8 +176,11 @@ pub(crate) enum ProjectError { #[error("Environment markers `{0}` don't overlap with Python requirement `{1}`")] DisjointEnvironment(MarkerTreeContents, VersionSpecifiers), - #[error("The workspace contains conflicting Python requirements:\n{}", _0.iter().map(|(name, specifiers)| format!("- `{name}`: `{specifiers}`")).join("\n"))] - DisjointRequiresPython(BTreeMap), + #[error( + "Found conflicting Python requirements:\n{}", + format_requires_python_sources(_0) + )] + DisjointRequiresPython(BTreeMap<(PackageName, Option), VersionSpecifiers>), #[error("Environment marker is empty")] EmptyEnvironment, @@ -286,7 +271,7 @@ pub(crate) struct ConflictError { /// The items from the set that were enabled, and thus create the conflict. pub(crate) conflicts: Vec, /// Enabled dependency groups with defaults applied. - pub(crate) dev: DependencyGroupsWithDefaults, + pub(crate) groups: DependencyGroupsWithDefaults, } impl std::fmt::Display for ConflictError { @@ -338,7 +323,7 @@ impl std::fmt::Display for ConflictError { .iter() .map(|conflict| match conflict { ConflictPackage::Group(group) - if self.dev.contains_because_default(group) => + if self.groups.contains_because_default(group) => format!("`{group}` (enabled by default)"), ConflictPackage::Group(group) => format!("`{group}`"), ConflictPackage::Extra(..) => unreachable!(), @@ -358,7 +343,7 @@ impl std::fmt::Display for ConflictError { let conflict = match conflict { ConflictPackage::Extra(extra) => format!("extra `{extra}`"), ConflictPackage::Group(group) - if self.dev.contains_because_default(group) => + if self.groups.contains_because_default(group) => { format!("group `{group}` (enabled by default)") } @@ -429,23 +414,16 @@ impl PlatformState { #[allow(clippy::result_large_err)] pub(crate) fn find_requires_python( workspace: &Workspace, + groups: &DependencyGroupsWithDefaults, ) -> Result, ProjectError> { + let requires_python = workspace.requires_python(groups)?; // If there are no `Requires-Python` specifiers in the workspace, return `None`. - if workspace.requires_python().next().is_none() { + if requires_python.is_empty() { return Ok(None); } - match RequiresPython::intersection( - workspace - .requires_python() - .map(|(.., specifiers)| specifiers), - ) { + match RequiresPython::intersection(requires_python.iter().map(|(.., specifiers)| specifiers)) { Some(requires_python) => Ok(Some(requires_python)), - None => Err(ProjectError::DisjointRequiresPython( - workspace - .requires_python() - .map(|(name, specifiers)| (name.clone(), specifiers.clone())) - .collect(), - )), + None => Err(ProjectError::DisjointRequiresPython(requires_python)), } } @@ -457,6 +435,7 @@ pub(crate) fn find_requires_python( pub(crate) fn validate_project_requires_python( interpreter: &Interpreter, workspace: Option<&Workspace>, + groups: &DependencyGroupsWithDefaults, requires_python: &RequiresPython, source: &PythonRequestSource, ) -> Result<(), ProjectError> { @@ -464,57 +443,24 @@ pub(crate) fn validate_project_requires_python( return Ok(()); } - // If the Python version is compatible with one of the workspace _members_, raise - // a dedicated error. For example, if the workspace root requires Python >=3.12, but - // a library in the workspace is compatible with Python >=3.8, the user may attempt - // to sync on Python 3.8. This will fail, but we should provide a more helpful error - // message. - for (name, member) in workspace.into_iter().flat_map(Workspace::packages) { - let Some(project) = member.pyproject_toml().project.as_ref() else { - continue; - }; - let Some(specifiers) = project.requires_python.as_ref() else { - continue; - }; - if specifiers.contains(interpreter.python_version()) { - return match source { - PythonRequestSource::UserRequest => { - Err(ProjectError::RequestedMemberIncompatibility( - interpreter.python_version().clone(), - requires_python.clone(), - name.clone(), - specifiers.clone(), - member.root().clone(), - )) - } - PythonRequestSource::DotPythonVersion(file) => { - Err(ProjectError::DotPythonVersionMemberIncompatibility( - file.path().user_display().to_string(), - interpreter.python_version().clone(), - requires_python.clone(), - name.clone(), - specifiers.clone(), - member.root().clone(), - )) - } - PythonRequestSource::RequiresPython => { - Err(ProjectError::RequiresPythonMemberIncompatibility( - interpreter.python_version().clone(), - requires_python.clone(), - name.clone(), - specifiers.clone(), - member.root().clone(), - )) - } - }; - } - } + // Find all the individual requires_python constraints that conflict + let conflicting_requires = workspace + .and_then(|workspace| workspace.requires_python(groups).ok()) + .into_iter() + .flatten() + .filter(|(.., requires)| !requires.contains(interpreter.python_version())) + .collect::(); + let workspace_non_trivial = workspace + .map(|workspace| workspace.packages().len() > 1) + .unwrap_or(false); match source { PythonRequestSource::UserRequest => { Err(ProjectError::RequestedPythonProjectIncompatibility( interpreter.python_version().clone(), requires_python.clone(), + conflicting_requires, + workspace_non_trivial, )) } PythonRequestSource::DotPythonVersion(file) => { @@ -522,12 +468,16 @@ pub(crate) fn validate_project_requires_python( file.path().user_display().to_string(), interpreter.python_version().clone(), requires_python.clone(), + conflicting_requires, + workspace_non_trivial, )) } PythonRequestSource::RequiresPython => { Err(ProjectError::RequiresPythonProjectIncompatibility( interpreter.python_version().clone(), requires_python.clone(), + conflicting_requires, + workspace_non_trivial, )) } } @@ -738,7 +688,13 @@ impl ScriptInterpreter { if let Err(err) = match requires_python { Some((requires_python, RequiresPythonSource::Project)) => { - validate_project_requires_python(&interpreter, workspace, &requires_python, &source) + validate_project_requires_python( + &interpreter, + workspace, + &DependencyGroupsWithDefaults::none(), + &requires_python, + &source, + ) } Some((requires_python, RequiresPythonSource::Script)) => { validate_script_requires_python(&interpreter, &requires_python, &source) @@ -874,6 +830,7 @@ impl ProjectInterpreter { pub(crate) async fn discover( workspace: &Workspace, project_dir: &Path, + groups: &DependencyGroupsWithDefaults, python_request: Option, network_settings: &NetworkSettings, python_preference: PythonPreference, @@ -890,8 +847,14 @@ impl ProjectInterpreter { source, python_request, requires_python, - } = WorkspacePython::from_request(python_request, Some(workspace), project_dir, no_config) - .await?; + } = WorkspacePython::from_request( + python_request, + Some(workspace), + groups, + project_dir, + no_config, + ) + .await?; // Read from the virtual environment first. let root = workspace.venv(active); @@ -1002,6 +965,7 @@ impl ProjectInterpreter { validate_project_requires_python( &interpreter, Some(workspace), + groups, requires_python, &source, )?; @@ -1081,10 +1045,14 @@ impl WorkspacePython { pub(crate) async fn from_request( python_request: Option, workspace: Option<&Workspace>, + groups: &DependencyGroupsWithDefaults, project_dir: &Path, no_config: bool, ) -> Result { - let requires_python = workspace.map(find_requires_python).transpose()?.flatten(); + let requires_python = workspace + .map(|workspace| find_requires_python(workspace, groups)) + .transpose()? + .flatten(); let workspace_root = workspace.map(Workspace::install_path); @@ -1165,6 +1133,8 @@ impl ScriptPython { } = WorkspacePython::from_request( python_request, workspace, + // Scripts have no groups to hang requires-python settings off of + &DependencyGroupsWithDefaults::none(), script.path().and_then(Path::parent).unwrap_or(&**CWD), no_config, ) @@ -1231,6 +1201,7 @@ impl ProjectEnvironment { /// Initialize a virtual environment for the current project. pub(crate) async fn get_or_init( workspace: &Workspace, + groups: &DependencyGroupsWithDefaults, python: Option, install_mirrors: &PythonInstallMirrors, network_settings: &NetworkSettings, @@ -1249,6 +1220,7 @@ impl ProjectEnvironment { match ProjectInterpreter::discover( workspace, workspace.install_path().as_ref(), + groups, python, network_settings, python_preference, @@ -2434,7 +2406,7 @@ pub(crate) fn default_dependency_groups( pub(crate) fn detect_conflicts( lock: &Lock, extras: &ExtrasSpecification, - dev: &DependencyGroupsWithDefaults, + groups: &DependencyGroupsWithDefaults, ) -> Result<(), ProjectError> { // Note that we need to collect all extras and groups that match in // a particular set, since extras can be declared as conflicting with @@ -2453,7 +2425,7 @@ pub(crate) fn detect_conflicts( } if item .group() - .map(|group| dev.contains(group)) + .map(|group| groups.contains(group)) .unwrap_or(false) { conflicts.push(item.conflict().clone()); @@ -2463,7 +2435,7 @@ pub(crate) fn detect_conflicts( return Err(ProjectError::Conflict(ConflictError { set: set.clone(), conflicts, - dev: dev.clone(), + groups: groups.clone(), })); } } @@ -2677,6 +2649,50 @@ fn cache_name(name: &str) -> Option> { } } +fn format_requires_python_sources(conflicts: &RequiresPythonSources) -> String { + conflicts + .iter() + .map(|((package, group), specifiers)| { + if let Some(group) = group { + format!("- {package}:{group}: {specifiers}") + } else { + format!("- {package}: {specifiers}") + } + }) + .join("\n") +} + +fn format_optional_requires_python_sources( + conflicts: &RequiresPythonSources, + workspace_non_trivial: bool, +) -> String { + // If there's lots of conflicts, print a list + if conflicts.len() > 1 { + return format!( + ".\nThe following `requires-python` declarations do not permit this version:\n{}", + format_requires_python_sources(conflicts) + ); + } + // If there's one conflict, give a clean message + if conflicts.len() == 1 { + let ((package, group), _) = conflicts.iter().next().unwrap(); + if let Some(group) = group { + if workspace_non_trivial { + return format!( + " (from workspace member `{package}`'s `tool.uv.dependency-groups.{group}.requires-python`)." + ); + } + return format!(" (from `tool.uv.dependency-groups.{group}.requires-python`)."); + } + if workspace_non_trivial { + return format!(" (from workspace member `{package}`'s `project.requires-python`)."); + } + return " (from `project.requires-python`)".to_owned(); + } + // Otherwise don't elaborate + String::new() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 6dab60012..d17cd88ed 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -13,7 +13,7 @@ use uv_configuration::{ PreviewMode, }; use uv_fs::Simplified; -use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras}; +use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras, DefaultGroups}; use uv_pep508::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_scripts::{Pep723ItemRef, Pep723Metadata, Pep723Script}; @@ -202,6 +202,14 @@ pub(crate) async fn remove( // Update the `pypackage.toml` in-memory. let target = target.update(&content)?; + // Determine enabled groups and extras + let default_groups = match &target { + RemoveTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?, + RemoveTarget::Script(_) => DefaultGroups::default(), + }; + let groups = DependencyGroups::default().with_defaults(default_groups); + let extras = ExtrasSpecification::default().with_defaults(DefaultExtras::default()); + // Convert to an `AddTarget` by attaching the appropriate interpreter or environment. let target = match target { RemoveTarget::Project(project) => { @@ -210,6 +218,7 @@ pub(crate) async fn remove( let interpreter = ProjectInterpreter::discover( project.workspace(), project_dir, + &groups, python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, @@ -229,6 +238,7 @@ pub(crate) async fn remove( // Discover or create the virtual environment. let environment = ProjectEnvironment::get_or_init( project.workspace(), + &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, &network_settings, @@ -314,12 +324,6 @@ pub(crate) async fn remove( return Ok(ExitStatus::Success); }; - // Determine the default groups to include. - let default_groups = default_dependency_groups(project.pyproject_toml())?; - - // Determine the default extras to include. - let default_extras = DefaultExtras::default(); - // Identify the installation target. let target = match &project { VirtualProject::Project(project) => InstallTarget::Project { @@ -338,8 +342,8 @@ pub(crate) async fn remove( match project::sync::do_sync( target, venv, - &ExtrasSpecification::default().with_defaults(default_extras), - &DependencyGroups::default().with_defaults(default_groups), + &extras, + &groups, EditableMode::Editable, InstallOptions::default(), Modifications::Exact, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 035528b1c..f97ffdbc1 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -78,7 +78,7 @@ pub(crate) async fn run( no_project: bool, no_config: bool, extras: ExtrasSpecification, - dev: DependencyGroups, + groups: DependencyGroups, editable: EditableMode, modifications: Modifications, python: Option, @@ -291,7 +291,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl target, &environment, &extras.with_defaults(DefaultExtras::default()), - &dev.with_defaults(DefaultGroups::default()), + &groups.with_defaults(DefaultGroups::default()), editable, install_options, modifications, @@ -468,7 +468,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl if !extras.is_empty() { warn_user!("Extras are not supported for Python scripts with inline metadata"); } - for flag in dev.history().as_flags_pretty() { + for flag in groups.history().as_flags_pretty() { warn_user!("`{flag}` is not supported for Python scripts with inline metadata"); } if all_packages { @@ -543,7 +543,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl for flag in extras.history().as_flags_pretty() { warn_user!("`{flag}` has no effect when used alongside `--no-project`"); } - for flag in dev.history().as_flags_pretty() { + for flag in groups.history().as_flags_pretty() { warn_user!("`{flag}` has no effect when used alongside `--no-project`"); } if locked { @@ -560,7 +560,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl for flag in extras.history().as_flags_pretty() { warn_user!("`{flag}` has no effect when used outside of a project"); } - for flag in dev.history().as_flags_pretty() { + for flag in groups.history().as_flags_pretty() { warn_user!("`{flag}` has no effect when used outside of a project"); } if locked { @@ -583,6 +583,11 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl project.workspace().install_path().display() ); } + // Determine the groups and extras to include. + let default_groups = default_dependency_groups(project.pyproject_toml())?; + let default_extras = DefaultExtras::default(); + let groups = groups.with_defaults(default_groups); + let extras = extras.with_defaults(default_extras); let venv = if isolated { debug!("Creating isolated virtual environment"); @@ -602,6 +607,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl } = WorkspacePython::from_request( python.as_deref().map(PythonRequest::parse), Some(project.workspace()), + &groups, project_dir, no_config, ) @@ -626,6 +632,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl validate_project_requires_python( &interpreter, Some(project.workspace()), + &groups, requires_python, &source, )?; @@ -647,6 +654,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // project. ProjectEnvironment::get_or_init( project.workspace(), + &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, &network_settings, @@ -677,14 +685,6 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl .map(|lock| (lock, project.workspace().install_path().to_owned())); } } else { - // Validate that any referenced dependency groups are defined in the workspace. - - // Determine the default groups to include. - let default_groups = default_dependency_groups(project.pyproject_toml())?; - - // Determine the default extras to include. - let default_extras = DefaultExtras::default(); - // Determine the lock mode. let mode = if frozen { LockMode::Frozen @@ -769,18 +769,15 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl }; let install_options = InstallOptions::default(); - let dev = dev.with_defaults(default_groups); - let extras = extras.with_defaults(default_extras); - // Validate that the set of requested extras and development groups are defined in the lockfile. target.validate_extras(&extras)?; - target.validate_groups(&dev)?; + target.validate_groups(&groups)?; match project::sync::do_sync( target, &venv, &extras, - &dev, + &groups, editable, install_options, modifications, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index ed96795e5..940b3a653 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -57,7 +57,7 @@ pub(crate) async fn sync( all_packages: bool, package: Option, extras: ExtrasSpecification, - dev: DependencyGroups, + groups: DependencyGroups, editable: EditableMode, install_options: InstallOptions, modifications: Modifications, @@ -116,23 +116,24 @@ pub(crate) async fn sync( SyncTarget::Project(project) }; - // Determine the default groups to include. + // Determine the groups and extras to include. let default_groups = match &target { SyncTarget::Project(project) => default_dependency_groups(project.pyproject_toml())?, SyncTarget::Script(..) => DefaultGroups::default(), }; - - // Determine the default extras to include. let default_extras = match &target { SyncTarget::Project(_project) => DefaultExtras::default(), SyncTarget::Script(..) => DefaultExtras::default(), }; + let groups = groups.with_defaults(default_groups); + let extras = extras.with_defaults(default_extras); // Discover or create the virtual environment. let environment = match &target { SyncTarget::Project(project) => SyncEnvironment::Project( ProjectEnvironment::get_or_init( project.workspace(), + &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, &network_settings, @@ -437,8 +438,8 @@ pub(crate) async fn sync( match do_sync( sync_target, &environment, - &extras.with_defaults(default_extras), - &dev.with_defaults(default_groups), + &extras, + &groups, editable, install_options, modifications, @@ -573,7 +574,7 @@ pub(super) async fn do_sync( target: InstallTarget<'_>, venv: &PythonEnvironment, extras: &ExtrasSpecificationWithDefaults, - dev: &DependencyGroupsWithDefaults, + groups: &DependencyGroupsWithDefaults, editable: EditableMode, install_options: InstallOptions, modifications: Modifications, @@ -624,11 +625,11 @@ pub(super) async fn do_sync( } // Validate that the set of requested extras and development groups are compatible. - detect_conflicts(target.lock(), extras, dev)?; + detect_conflicts(target.lock(), extras, groups)?; // Validate that the set of requested extras and development groups are defined in the lockfile. target.validate_extras(extras)?; - target.validate_groups(dev)?; + target.validate_groups(groups)?; // Determine the markers to use for resolution. let marker_env = venv.interpreter().resolver_marker_environment(); @@ -665,7 +666,7 @@ pub(super) async fn do_sync( &marker_env, tags, extras, - dev, + groups, build_options, &install_options, )?; diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 6bf57d1a7..9c42a8a86 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -34,7 +34,7 @@ use crate::settings::{NetworkSettings, ResolverSettings}; #[allow(clippy::fn_params_excessive_bools)] pub(crate) async fn tree( project_dir: &Path, - dev: DependencyGroups, + groups: DependencyGroups, locked: bool, frozen: bool, universal: bool, @@ -71,11 +71,12 @@ pub(crate) async fn tree( LockTarget::Workspace(&workspace) }; - // Determine the default groups to include. - let defaults = match target { + // Determine the groups to include. + let default_groups = match target { LockTarget::Workspace(workspace) => default_dependency_groups(workspace.pyproject_toml())?, LockTarget::Script(_) => DefaultGroups::default(), }; + let groups = groups.with_defaults(default_groups); let native_tls = network_settings.native_tls; @@ -102,6 +103,7 @@ pub(crate) async fn tree( LockTarget::Workspace(workspace) => ProjectInterpreter::discover( workspace, project_dir, + &groups, python.as_deref().map(PythonRequest::parse), network_settings, python_preference, @@ -271,7 +273,7 @@ pub(crate) async fn tree( depth.into(), &prune, &package, - &dev.with_defaults(defaults), + &groups, no_dedupe, invert, ); diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 0e50c2ac0..f76744186 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -10,8 +10,8 @@ use uv_cache::Cache; use uv_cli::version::VersionInfo; use uv_cli::{VersionBump, VersionFormat}; use uv_configuration::{ - Concurrency, DependencyGroups, DryRun, EditableMode, ExtrasSpecification, InstallOptions, - PreviewMode, + Concurrency, DependencyGroups, DependencyGroupsWithDefaults, DryRun, EditableMode, + ExtrasSpecification, InstallOptions, PreviewMode, }; use uv_fs::Simplified; use uv_normalize::DefaultExtras; @@ -285,6 +285,7 @@ async fn print_frozen_version( let interpreter = ProjectInterpreter::discover( project.workspace(), project_dir, + &DependencyGroupsWithDefaults::none(), python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, @@ -378,12 +379,20 @@ async fn lock_and_sync( return Ok(ExitStatus::Success); } + // Determine the groups and extras that should be enabled. + let default_groups = default_dependency_groups(project.pyproject_toml())?; + let default_extras = DefaultExtras::default(); + let groups = DependencyGroups::default().with_defaults(default_groups); + let extras = ExtrasSpecification::from_all_extras().with_defaults(default_extras); + let install_options = InstallOptions::default(); + // Convert to an `AddTarget` by attaching the appropriate interpreter or environment. let target = if no_sync { // Discover the interpreter. let interpreter = ProjectInterpreter::discover( project.workspace(), project_dir, + &groups, python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, @@ -403,6 +412,7 @@ async fn lock_and_sync( // Discover or create the virtual environment. let environment = ProjectEnvironment::get_or_init( project.workspace(), + &groups, python.as_deref().map(PythonRequest::parse), &install_mirrors, &network_settings, @@ -466,15 +476,6 @@ async fn lock_and_sync( }; // Perform a full sync, because we don't know what exactly is affected by the version. - // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? - let extras = ExtrasSpecification::from_all_extras(); - let install_options = InstallOptions::default(); - - // Determine the default groups to include. - let default_groups = default_dependency_groups(project.pyproject_toml())?; - - // Determine the default extras to include. - let default_extras = DefaultExtras::default(); // Identify the installation target. let target = match &project { @@ -494,8 +495,8 @@ async fn lock_and_sync( match project::sync::do_sync( target, venv, - &extras.with_defaults(default_extras), - &DependencyGroups::default().with_defaults(default_groups), + &extras, + &groups, EditableMode::Editable, install_options, Modifications::Sufficient, diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 63e25fed1..1e5693c65 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -1,6 +1,7 @@ use anyhow::Result; use std::fmt::Write; use std::path::Path; +use uv_configuration::DependencyGroupsWithDefaults; use uv_cache::Cache; use uv_fs::Simplified; @@ -56,6 +57,8 @@ pub(crate) async fn find( } }; + // Don't enable the requires-python settings on groups + let groups = DependencyGroupsWithDefaults::none(); let WorkspacePython { source, python_request, @@ -63,6 +66,7 @@ pub(crate) async fn find( } = WorkspacePython::from_request( request.map(|request| PythonRequest::parse(&request)), project.as_ref().map(VirtualProject::workspace), + &groups, project_dir, no_config, ) @@ -80,6 +84,7 @@ pub(crate) async fn find( match validate_project_requires_python( python.interpreter(), project.as_ref().map(VirtualProject::workspace), + &groups, &requires_python, &source, ) { diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index e0b241bcc..a0af7ec41 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -8,6 +8,7 @@ use tracing::debug; use uv_cache::Cache; use uv_client::BaseClientBuilder; +use uv_configuration::DependencyGroupsWithDefaults; use uv_dirs::user_uv_config_dir; use uv_fs::Simplified; use uv_python::{ @@ -322,6 +323,9 @@ struct Pin<'a> { /// Checks if the pinned Python version is compatible with the workspace/project's `Requires-Python`. fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProject) -> Result<()> { + // Don't factor in requires-python settings on dependency-groups + let groups = DependencyGroupsWithDefaults::none(); + let (requires_python, project_type) = match virtual_project { VirtualProject::Project(project_workspace) => { debug!( @@ -329,7 +333,8 @@ fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProjec project_workspace.project_name(), project_workspace.workspace().install_path().display() ); - let requires_python = find_requires_python(project_workspace.workspace())?; + + let requires_python = find_requires_python(project_workspace.workspace(), &groups)?; (requires_python, "project") } VirtualProject::NonProject(workspace) => { @@ -337,7 +342,7 @@ fn assert_pin_compatible_with_project(pin: &Pin, virtual_project: &VirtualProjec "Discovered virtual workspace at: {}", workspace.install_path().display() ); - let requires_python = find_requires_python(workspace)?; + let requires_python = find_requires_python(workspace, &groups)?; (requires_python, "workspace") } }; diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index c0cf03921..a50c0e155 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -13,14 +13,15 @@ use thiserror::Error; use uv_cache::Cache; use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - BuildOptions, Concurrency, ConfigSettings, Constraints, IndexStrategy, KeyringProviderType, - NoBinary, NoBuild, PreviewMode, SourceStrategy, + BuildOptions, Concurrency, ConfigSettings, Constraints, DependencyGroups, IndexStrategy, + KeyringProviderType, NoBinary, NoBuild, PreviewMode, SourceStrategy, }; use uv_dispatch::{BuildDispatch, SharedState}; use uv_distribution_types::Requirement; use uv_distribution_types::{DependencyMetadata, Index, IndexLocations}; use uv_fs::Simplified; use uv_install_wheel::LinkMode; +use uv_normalize::DefaultGroups; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, }; @@ -39,6 +40,8 @@ use crate::commands::reporters::PythonDownloadReporter; use crate::printer::Printer; use crate::settings::NetworkSettings; +use super::project::default_dependency_groups; + /// Create a virtual environment. #[allow(clippy::unnecessary_wraps, clippy::fn_params_excessive_bools)] pub(crate) async fn venv( @@ -197,6 +200,13 @@ async fn venv_impl( let reporter = PythonDownloadReporter::single(printer); + // If the default dependency-groups demand a higher requires-python + // we should bias an empty venv to that to avoid churn. + let default_groups = match &project { + Some(project) => default_dependency_groups(project.pyproject_toml()).into_diagnostic()?, + None => DefaultGroups::default(), + }; + let groups = DependencyGroups::default().with_defaults(default_groups); let WorkspacePython { source, python_request, @@ -204,6 +214,7 @@ async fn venv_impl( } = WorkspacePython::from_request( python_request.map(PythonRequest::parse), project.as_ref().map(VirtualProject::workspace), + &groups, project_dir, no_config, ) @@ -246,6 +257,7 @@ async fn venv_impl( match validate_project_requires_python( &interpreter, project.as_ref().map(VirtualProject::workspace), + &groups, &requires_python, &source, ) { diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index b7b1a7859..51041bcbc 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1704,7 +1704,7 @@ async fn run_project( args.no_project, no_config, args.extras, - args.dev, + args.groups, args.editable, args.modifications, args.python, @@ -1752,7 +1752,7 @@ async fn run_project( args.all_packages, args.package, args.extras, - args.dev, + args.groups, args.editable, args.install_options, args.modifications, @@ -2043,7 +2043,7 @@ async fn run_project( Box::pin(commands::tree( project_dir, - args.dev, + args.groups, args.locked, args.frozen, args.universal, @@ -2095,7 +2095,7 @@ async fn run_project( args.install_options, args.output_file, args.extras, - args.dev, + args.groups, args.editable, args.locked, args.frozen, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index b5eb2f5d0..f8d44b50c 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -313,7 +313,7 @@ pub(crate) struct RunSettings { pub(crate) locked: bool, pub(crate) frozen: bool, pub(crate) extras: ExtrasSpecification, - pub(crate) dev: DependencyGroups, + pub(crate) groups: DependencyGroups, pub(crate) editable: EditableMode, pub(crate) modifications: Modifications, pub(crate) with: Vec, @@ -404,7 +404,7 @@ impl RunSettings { vec![], flag(all_extras, no_all_extras).unwrap_or_default(), ), - dev: DependencyGroups::from_args( + groups: DependencyGroups::from_args( dev, no_dev, only_dev, @@ -1098,7 +1098,7 @@ pub(crate) struct SyncSettings { pub(crate) script: Option, pub(crate) active: Option, pub(crate) extras: ExtrasSpecification, - pub(crate) dev: DependencyGroups, + pub(crate) groups: DependencyGroups, pub(crate) editable: EditableMode, pub(crate) install_options: InstallOptions, pub(crate) modifications: Modifications, @@ -1180,7 +1180,7 @@ impl SyncSettings { vec![], flag(all_extras, no_all_extras).unwrap_or_default(), ), - dev: DependencyGroups::from_args( + groups: DependencyGroups::from_args( dev, no_dev, only_dev, @@ -1578,7 +1578,7 @@ impl VersionSettings { #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct TreeSettings { - pub(crate) dev: DependencyGroups, + pub(crate) groups: DependencyGroups, pub(crate) locked: bool, pub(crate) frozen: bool, pub(crate) universal: bool, @@ -1626,7 +1626,7 @@ impl TreeSettings { .unwrap_or_default(); Self { - dev: DependencyGroups::from_args( + groups: DependencyGroups::from_args( dev, no_dev, only_dev, @@ -1664,7 +1664,7 @@ pub(crate) struct ExportSettings { pub(crate) package: Option, pub(crate) prune: Vec, pub(crate) extras: ExtrasSpecification, - pub(crate) dev: DependencyGroups, + pub(crate) groups: DependencyGroups, pub(crate) editable: EditableMode, pub(crate) hashes: bool, pub(crate) install_options: InstallOptions, @@ -1739,7 +1739,7 @@ impl ExportSettings { vec![], flag(all_extras, no_all_extras).unwrap_or_default(), ), - dev: DependencyGroups::from_args( + groups: DependencyGroups::from_args( dev, no_dev, only_dev, diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 4387d348a..260211269 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -5259,16 +5259,16 @@ fn lock_requires_python_disjoint() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: The workspace contains conflicting Python requirements: - - `child`: `==3.10` - - `project`: `>=3.12` - "###); + error: Found conflicting Python requirements: + - child: ==3.10 + - project: >=3.12 + "); Ok(()) } @@ -18298,29 +18298,30 @@ fn lock_request_requires_python() -> Result<()> { )?; // Request a version that conflicts with `--requires-python`. - uv_snapshot!(context.filters(), context.lock().arg("--python").arg("3.12"), @r###" + uv_snapshot!(context.filters(), context.lock().arg("--python").arg("3.12"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] - error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10` - "###); + error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10` (from `project.requires-python`) + "); // Add a `.python-version` file that conflicts. let python_version = context.temp_dir.child(".python-version"); python_version.write_str("3.12")?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] - error: The Python request from `.python-version` resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10`. Use `uv python pin` to update the `.python-version` file to a compatible version. - "###); + error: The Python request from `.python-version` resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10` (from `project.requires-python`) + Use `uv python pin` to update the `.python-version` file to a compatible version + "); Ok(()) } @@ -20660,6 +20661,465 @@ fn lock_group_include() -> Result<()> { Ok(()) } +#[test] +fn lock_group_requires_python() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [dependency-groups] + foo = ["idna"] + bar = ["sortedcontainers", "sniffio"] + + [tool.uv.dependency-groups] + bar = { requires-python = ">=3.13" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = ">=3.12" + resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.dev-dependencies] + bar = [ + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + foo = [ + { name = "idna" }, + ] + + [package.metadata] + requires-dist = [{ name = "typing-extensions" }] + + [package.metadata.requires-dev] + bar = [ + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + foo = [{ name = "idna" }] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558, upload-time = "2024-02-25T22:12:49.693Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926, upload-time = "2024-02-25T22:12:47.72Z" }, + ] + "# + ); + }); + + Ok(()) +} + +#[test] +fn lock_group_includes_requires_python() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [dependency-groups] + foo = ["idna", {include-group = "bar"}] + bar = ["sortedcontainers", "sniffio"] + baz = ["idna", {include-group = "bar"}] + blargh = ["idna", {include-group = "bar"}] + + [tool.uv.dependency-groups] + bar = { requires-python = ">=3.13" } + baz = { requires-python = ">=3.13.1" } + blargh = { requires-python = ">=3.12.1" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = ">=3.12" + resolution-markers = [ + "python_full_version >= '3.13.1'", + "python_full_version >= '3.13' and python_full_version < '3.13.1'", + "python_full_version >= '3.12.[X]' and python_full_version < '3.13'", + "python_full_version < '3.12.[X]'", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.dev-dependencies] + bar = [ + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + baz = [ + { name = "idna", marker = "python_full_version >= '3.13.1'" }, + { name = "sniffio", marker = "python_full_version >= '3.13.1'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13.1'" }, + ] + blargh = [ + { name = "idna", marker = "python_full_version >= '3.12.[X]'" }, + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + foo = [ + { name = "idna" }, + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + + [package.metadata] + requires-dist = [{ name = "typing-extensions" }] + + [package.metadata.requires-dev] + bar = [ + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + baz = [ + { name = "idna", marker = "python_full_version >= '3.13.1'" }, + { name = "sniffio", marker = "python_full_version >= '3.13.1'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13.1'" }, + ] + blargh = [ + { name = "idna", marker = "python_full_version >= '3.12.[X]'" }, + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + foo = [ + { name = "idna" }, + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558, upload-time = "2024-02-25T22:12:49.693Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926, upload-time = "2024-02-25T22:12:47.72Z" }, + ] + "# + ); + }); + + Ok(()) +} + +/// Referring to a dependency-group with group-requires-python that does not exist +#[test] +fn lock_group_requires_undefined_group() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "myproject" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [dependency-groups] + bar = ["sortedcontainers"] + + [tool.uv.dependency-groups] + foo = { requires-python = ">=3.13" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Project `myproject` has malformed dependency groups + Caused by: Failed to find group `foo` specified in `[tool.uv.dependency-groups]` + "); + Ok(()) +} + +/// The legacy dev-dependencies cannot be referred to by group-requires-python +#[test] +fn lock_group_requires_dev_dep() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "myproject" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [tool.uv] + dev-dependencies = ["sortedcontainers"] + + [tool.uv.dependency-groups] + dev = { requires-python = ">=3.13" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Project `myproject` has malformed dependency groups + Caused by: `[tool.uv.dependency-groups]` specifies the `dev` group, but only `tool.uv.dev-dependencies` was found. To reference the `dev` group, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead. + "); + Ok(()) +} + +#[test] +fn lock_group_includes_requires_python_contradiction() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["typing-extensions"] + + [dependency-groups] + foo = ["idna", {include-group = "bar"}] + bar = ["sortedcontainers", "sniffio"] + + [tool.uv.dependency-groups] + bar = { requires-python = ">=3.13" } + foo = { requires-python = "<3.13" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 5 packages in [TIME] + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 2 + requires-python = ">=3.12" + resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", + ] + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "idna" + version = "3.6" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.dev-dependencies] + bar = [ + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + foo = [ + { name = "idna", marker = "python_full_version < '3.13'" }, + ] + + [package.metadata] + requires-dist = [{ name = "typing-extensions" }] + + [package.metadata.requires-dev] + bar = [ + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + foo = [ + { name = "idna", marker = "python_full_version < '3.13'" }, + { name = "sniffio", marker = "python_version < '0'" }, + { name = "sortedcontainers", marker = "python_version < '0'" }, + ] + + [[package]] + name = "sniffio" + version = "1.3.1" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, + ] + + [[package]] + name = "sortedcontainers" + version = "2.4.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, + ] + + [[package]] + name = "typing-extensions" + version = "4.10.0" + source = { registry = "https://pypi.org/simple" } + sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558, upload-time = "2024-02-25T22:12:49.693Z" } + wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926, upload-time = "2024-02-25T22:12:47.72Z" }, + ] + "# + ); + }); + + Ok(()) +} + #[test] fn lock_group_include_cycle() -> Result<()> { let context = TestContext::new("3.12"); @@ -20680,15 +21140,15 @@ fn lock_group_include_cycle() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `project @ file://[TEMP_DIR]/` - ╰─▶ Detected a cycle in `dependency-groups`: `bar` -> `foobar` -> `foo` -> `bar` - "###); + error: Project `project` has malformed dependency groups + Caused by: Detected a cycle in `dependency-groups`: `bar` -> `foobar` -> `foo` -> `bar` + "); Ok(()) } @@ -20714,15 +21174,15 @@ fn lock_group_include_dev() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r#" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `project @ file://[TEMP_DIR]/` - ╰─▶ Group `foo` includes the `dev` group (`include = "dev"`), but only `tool.uv.dev-dependencies` was found. To reference the `dev` group via an `include`, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead. - "###); + error: Project `project` has malformed dependency groups + Caused by: Group `foo` includes the `dev` group (`include = "dev"`), but only `tool.uv.dev-dependencies` was found. To reference the `dev` group via an `include`, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead. + "#); Ok(()) } @@ -20745,15 +21205,15 @@ fn lock_group_include_missing() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `project @ file://[TEMP_DIR]/` - ╰─▶ Failed to find group `bar` included by `foo` - "###); + error: Project `project` has malformed dependency groups + Caused by: Failed to find group `bar` included by `foo` + "); Ok(()) } @@ -20776,31 +21236,31 @@ fn lock_group_invalid_entry_package() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r#" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `project @ file://[TEMP_DIR]/` - ├─▶ Failed to parse entry in group `foo`: `invalid!` - ╰─▶ no such comparison operator "!", must be one of ~= == != <= >= < > === - invalid! - ^ - "###); + error: Project `project` has malformed dependency groups + Caused by: Failed to parse entry in group `foo`: `invalid!` + Caused by: no such comparison operator "!", must be one of ~= == != <= >= < > === + invalid! + ^ + "#); - uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo"), @r#" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `project @ file://[TEMP_DIR]/` - ├─▶ Failed to parse entry in group `foo`: `invalid!` - ╰─▶ no such comparison operator "!", must be one of ~= == != <= >= < > === - invalid! - ^ - "###); + error: Project `project` has malformed dependency groups + Caused by: Failed to parse entry in group `foo`: `invalid!` + Caused by: no such comparison operator "!", must be one of ~= == != <= >= < > === + invalid! + ^ + "#); Ok(()) } @@ -20897,12 +21357,12 @@ fn lock_group_invalid_entry_table() -> Result<()> { uv_snapshot!(context.filters(), context.lock(), @r#" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `project @ file://[TEMP_DIR]/` - ╰─▶ Group `foo` contains an unknown dependency object specifier: {"bar": "unknown"} + error: Project `project` has malformed dependency groups + Caused by: Group `foo` contains an unknown dependency object specifier: {"bar": "unknown"} "#); Ok(()) diff --git a/crates/uv/tests/it/python_find.rs b/crates/uv/tests/it/python_find.rs index 11bf421a3..f438e9b4d 100644 --- a/crates/uv/tests/it/python_find.rs +++ b/crates/uv/tests/it/python_find.rs @@ -318,15 +318,15 @@ fn python_find_project() { "###); // Unless explicitly requested - uv_snapshot!(context.filters(), context.python_find().arg("3.10"), @r###" + uv_snapshot!(context.filters(), context.python_find().arg("3.10"), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.10] ----- stderr ----- - warning: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` - "###); + warning: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` (from `project.requires-python`) + "); // Or `--no-project` is used uv_snapshot!(context.filters(), context.python_find().arg("--no-project"), @r###" @@ -367,15 +367,16 @@ fn python_find_project() { "###); // We should warn on subsequent uses, but respect the pinned version? - uv_snapshot!(context.filters(), context.python_find(), @r###" + uv_snapshot!(context.filters(), context.python_find(), @r" success: true exit_code: 0 ----- stdout ----- [PYTHON-3.10] ----- stderr ----- - warning: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`. Use `uv python pin` to update the `.python-version` file to a compatible version. - "###); + warning: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` (from `project.requires-python`) + Use `uv python pin` to update the `.python-version` file to a compatible version + "); // Unless the pin file is outside the project, in which case we should just ignore it let child_dir = context.temp_dir.child("child"); diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 121915ef0..65d13c527 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -133,7 +133,7 @@ fn run_with_python_version() -> Result<()> { ----- stderr ----- Using CPython 3.9.[X] interpreter at: [PYTHON-3.9] - error: The requested interpreter resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.11, <4` + error: The requested interpreter resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.11, <4` (from `project.requires-python`) "); Ok(()) @@ -3136,25 +3136,27 @@ fn run_isolated_incompatible_python() -> Result<()> { })?; // We should reject Python 3.9... - uv_snapshot!(context.filters(), context.run().arg("main.py"), @r###" + uv_snapshot!(context.filters(), context.run().arg("main.py"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- Using CPython 3.9.[X] interpreter at: [PYTHON-3.9] - error: The Python request from `.python-version` resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12`. Use `uv python pin` to update the `.python-version` file to a compatible version. - "###); + error: The Python request from `.python-version` resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`) + Use `uv python pin` to update the `.python-version` file to a compatible version + "); // ...even if `--isolated` is provided. - uv_snapshot!(context.filters(), context.run().arg("--isolated").arg("main.py"), @r###" + uv_snapshot!(context.filters(), context.run().arg("--isolated").arg("main.py"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: The Python request from `.python-version` resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12`. Use `uv python pin` to update the `.python-version` file to a compatible version. - "###); + error: The Python request from `.python-version` resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`) + Use `uv python pin` to update the `.python-version` file to a compatible version + "); Ok(()) } @@ -4598,6 +4600,249 @@ fn run_default_groups() -> Result<()> { Ok(()) } + +#[test] +fn run_groups_requires_python() -> Result<()> { + let context = + TestContext::new_with_versions(&["3.11", "3.12", "3.13"]).with_filtered_python_sources(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = ["typing-extensions"] + + [dependency-groups] + foo = ["anyio"] + bar = ["iniconfig"] + dev = ["sniffio"] + + [tool.uv.dependency-groups] + foo = {requires-python=">=3.14"} + bar = {requires-python=">=3.13"} + dev = {requires-python=">=3.12"} + "#, + )?; + + context.lock().assert().success(); + + // With --no-default-groups only the main requires-python should be consulted + uv_snapshot!(context.filters(), context.run() + .arg("--no-default-groups") + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + Resolved 6 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + typing-extensions==4.10.0 + "); + + // The main requires-python and the default group's requires-python should be consulted + // (This should trigger a version bump) + uv_snapshot!(context.filters(), context.run() + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 6 packages in [TIME] + Prepared 1 package in [TIME] + Installed 2 packages in [TIME] + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "); + + // The main requires-python and "dev" and "bar" requires-python should be consulted + // (This should trigger a version bump) + uv_snapshot!(context.filters(), context.run() + .arg("--group").arg("bar") + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.13.[X] interpreter at: [PYTHON-3.13] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 6 packages in [TIME] + Prepared 1 package in [TIME] + Installed 3 packages in [TIME] + + iniconfig==2.0.0 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "); + + // Going back to just "dev" we shouldn't churn the venv needlessly + uv_snapshot!(context.filters(), context.run() + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Audited 2 packages in [TIME] + "); + + // Explicitly requesting an in-range python can downgrade + uv_snapshot!(context.filters(), context.run() + .arg("-p").arg("3.12") + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 6 packages in [TIME] + Installed 2 packages in [TIME] + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "); + + // Explicitly requesting an out-of-range python fails + uv_snapshot!(context.filters(), context.run() + .arg("-p").arg("3.11") + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + error: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `tool.uv.dependency-groups.dev.requires-python`). + "); + + // Enabling foo we can't find an interpreter + uv_snapshot!(context.filters(), context.run() + .arg("--group").arg("foo") + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: No interpreter found for Python >=3.14 in [PYTHON SOURCES] + "); + + Ok(()) +} + +#[test] +fn run_groups_include_requires_python() -> Result<()> { + let context = TestContext::new_with_versions(&["3.11", "3.12", "3.13"]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = ["typing-extensions"] + + [dependency-groups] + foo = ["anyio"] + bar = ["iniconfig"] + baz = ["iniconfig"] + dev = ["sniffio", {include-group = "foo"}, {include-group = "baz"}] + + + [tool.uv.dependency-groups] + foo = {requires-python="<3.13"} + bar = {requires-python=">=3.13"} + baz = {requires-python=">=3.12"} + "#, + )?; + + context.lock().assert().success(); + + // With --no-default-groups only the main requires-python should be consulted + uv_snapshot!(context.filters(), context.run() + .arg("--no-default-groups") + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + Resolved 6 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + typing-extensions==4.10.0 + "); + + // The main requires-python and the default group's requires-python should be consulted + // (This should trigger a version bump) + uv_snapshot!(context.filters(), context.run() + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 6 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 5 packages in [TIME] + + anyio==4.3.0 + + idna==3.6 + + iniconfig==2.0.0 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "); + + // The main requires-python and "dev" and "bar" requires-python should be consulted + // (This should trigger a conflict) + uv_snapshot!(context.filters(), context.run() + .arg("--group").arg("bar") + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Found conflicting Python requirements: + - project: >=3.11 + - project:bar: >=3.13 + - project:dev: >=3.12, <3.13 + "); + + // Explicitly requesting an out-of-range python fails + uv_snapshot!(context.filters(), context.run() + .arg("-p").arg("3.13") + .arg("python").arg("-c").arg("import typing_extensions"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.13.[X] interpreter at: [PYTHON-3.13] + error: The requested interpreter resolved to Python 3.13.[X], which is incompatible with the project's Python requirement: `==3.12.*` (from `tool.uv.dependency-groups.dev.requires-python`). + "); + Ok(()) +} + /// Test that a signal n makes the process exit with code 128+n. #[cfg(unix)] #[test] diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 87453090c..7635bd523 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -3987,7 +3987,7 @@ fn resolve_config_file() -> anyhow::Result<()> { | 1 | [project] | ^^^^^^^ - unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dev-dependencies`, `build-backend` + unknown field `project`, expected one of `required-version`, `native-tls`, `offline`, `no-cache`, `cache-dir`, `preview`, `python-preference`, `python-downloads`, `concurrent-downloads`, `concurrent-builds`, `concurrent-installs`, `index`, `index-url`, `extra-index-url`, `no-index`, `find-links`, `index-strategy`, `keyring-provider`, `allow-insecure-host`, `resolution`, `prerelease`, `fork-strategy`, `dependency-metadata`, `config-settings`, `no-build-isolation`, `no-build-isolation-package`, `exclude-newer`, `link-mode`, `compile-bytecode`, `no-sources`, `upgrade`, `upgrade-package`, `reinstall`, `reinstall-package`, `no-build`, `no-build-package`, `no-binary`, `no-binary-package`, `python-install-mirror`, `pypy-install-mirror`, `python-downloads-json-url`, `publish-url`, `trusted-publishing`, `check-url`, `add-bounds`, `pip`, `cache-keys`, `override-dependencies`, `constraint-dependencies`, `build-constraint-dependencies`, `environments`, `required-environments`, `conflicts`, `workspace`, `sources`, `managed`, `package`, `default-groups`, `dependency-groups`, `dev-dependencies`, `build-backend` " ); diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 3f3cd072c..70d8a9118 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -346,7 +346,296 @@ fn mixed_requires_python() -> Result<()> { ----- stderr ----- Using CPython 3.9.[X] interpreter at: [PYTHON-3.9] - error: The requested interpreter resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12`. However, a workspace member (`bird-feeder`) supports Python >=3.9. To install the workspace member on its own, navigate to `packages/bird-feeder`, then run `uv venv --python 3.9.[X]` followed by `uv pip install -e .`. + error: The requested interpreter resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12` (from workspace member `albatross`'s `project.requires-python`). + "); + + Ok(()) +} + +/// Ensure that group requires-python solves an actual problem +#[test] +#[cfg(not(windows))] +fn group_requires_python_useful_defaults() -> Result<()> { + let context = TestContext::new_with_versions(&["3.8", "3.9"]); + + // Require 3.8 for our project, but have a dev-dependency on a version of sphinx that needs 3.9 + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pharaohs-tomp" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["anyio"] + + [dependency-groups] + dev = ["sphinx>=7.2.6"] + "#, + )?; + + let src = context.temp_dir.child("src").child("albatross"); + src.create_dir_all()?; + + let init = src.child("__init__.py"); + init.touch()?; + + // Running `uv sync --no-dev` should ideally succeed, locking for Python 3.8. + // ...but once we pick the 3.8 interpreter the lock freaks out because it sees + // that the dependency-group containing sphinx will never successfully install, + // even though it's not enabled! + uv_snapshot!(context.filters(), context.sync() + .arg("--no-dev"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.8.[X] interpreter at: [PYTHON-3.8] + Creating virtual environment at: .venv + × No solution found when resolving dependencies for split (python_full_version == '3.8.*'): + ╰─▶ Because the requested Python version (>=3.8) does not satisfy Python>=3.9 and sphinx==7.2.6 depends on Python>=3.9, we can conclude that sphinx==7.2.6 cannot be used. + And because only sphinx<=7.2.6 is available, we can conclude that sphinx>=7.2.6 cannot be used. + And because pharaohs-tomp:dev depends on sphinx>=7.2.6 and your project requires pharaohs-tomp:dev, we can conclude that your project's requirements are unsatisfiable. + + hint: The `requires-python` value (>=3.8) includes Python versions that are not supported by your dependencies (e.g., sphinx==7.2.6 only supports >=3.9). Consider using a more restrictive `requires-python` value (like >=3.9). + "); + + // Running `uv sync` should always fail, as now sphinx is involved + uv_snapshot!(context.filters(), context.sync(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies for split (python_full_version == '3.8.*'): + ╰─▶ Because the requested Python version (>=3.8) does not satisfy Python>=3.9 and sphinx==7.2.6 depends on Python>=3.9, we can conclude that sphinx==7.2.6 cannot be used. + And because only sphinx<=7.2.6 is available, we can conclude that sphinx>=7.2.6 cannot be used. + And because pharaohs-tomp:dev depends on sphinx>=7.2.6 and your project requires pharaohs-tomp:dev, we can conclude that your project's requirements are unsatisfiable. + + hint: The `requires-python` value (>=3.8) includes Python versions that are not supported by your dependencies (e.g., sphinx==7.2.6 only supports >=3.9). Consider using a more restrictive `requires-python` value (like >=3.9). + "); + + // Adding group requires python should fix it + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pharaohs-tomp" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["anyio"] + + [dependency-groups] + dev = ["sphinx>=7.2.6"] + + [tool.uv.dependency-groups] + dev = {requires-python = ">=3.9"} + "#, + )?; + + // Running `uv sync --no-dev` should succeed, still using the Python 3.8. + uv_snapshot!(context.filters(), context.sync() + .arg("--no-dev"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 29 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + anyio==4.3.0 + + exceptiongroup==1.2.0 + + idna==3.6 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "); + + // Running `uv sync` should succeed, bumping to Python 3.9 as sphinx is now involved. + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.9.[X] interpreter at: [PYTHON-3.9] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 29 packages in [TIME] + Prepared 22 packages in [TIME] + Installed 27 packages in [TIME] + + alabaster==0.7.16 + + anyio==4.3.0 + + babel==2.14.0 + + certifi==2024.2.2 + + charset-normalizer==3.3.2 + + docutils==0.20.1 + + exceptiongroup==1.2.0 + + idna==3.6 + + imagesize==1.4.1 + + importlib-metadata==7.1.0 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + packaging==24.0 + + pygments==2.17.2 + + requests==2.31.0 + + sniffio==1.3.1 + + snowballstemmer==2.2.0 + + sphinx==7.2.6 + + sphinxcontrib-applehelp==1.0.8 + + sphinxcontrib-devhelp==1.0.6 + + sphinxcontrib-htmlhelp==2.0.5 + + sphinxcontrib-jsmath==1.0.1 + + sphinxcontrib-qthelp==1.0.7 + + sphinxcontrib-serializinghtml==1.1.10 + + typing-extensions==4.10.0 + + urllib3==2.2.1 + + zipp==3.18.1 + "); + + Ok(()) +} + +/// Ensure that group requires-python solves an actual problem +#[test] +#[cfg(not(windows))] +fn group_requires_python_useful_non_defaults() -> Result<()> { + let context = TestContext::new_with_versions(&["3.8", "3.9"]); + + // Require 3.8 for our project, but have a dev-dependency on a version of sphinx that needs 3.9 + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pharaohs-tomp" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["anyio"] + + [dependency-groups] + mygroup = ["sphinx>=7.2.6"] + "#, + )?; + + let src = context.temp_dir.child("src").child("albatross"); + src.create_dir_all()?; + + let init = src.child("__init__.py"); + init.touch()?; + + // Running `uv sync` should ideally succeed, locking for Python 3.8. + // ...but once we pick the 3.8 interpreter the lock freaks out because it sees + // that the dependency-group containing sphinx will never successfully install, + // even though it's not enabled, or even a default! + uv_snapshot!(context.filters(), context.sync(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.8.[X] interpreter at: [PYTHON-3.8] + Creating virtual environment at: .venv + × No solution found when resolving dependencies for split (python_full_version == '3.8.*'): + ╰─▶ Because the requested Python version (>=3.8) does not satisfy Python>=3.9 and sphinx==7.2.6 depends on Python>=3.9, we can conclude that sphinx==7.2.6 cannot be used. + And because only sphinx<=7.2.6 is available, we can conclude that sphinx>=7.2.6 cannot be used. + And because pharaohs-tomp:mygroup depends on sphinx>=7.2.6 and your project requires pharaohs-tomp:mygroup, we can conclude that your project's requirements are unsatisfiable. + + hint: The `requires-python` value (>=3.8) includes Python versions that are not supported by your dependencies (e.g., sphinx==7.2.6 only supports >=3.9). Consider using a more restrictive `requires-python` value (like >=3.9). + "); + + // Running `uv sync --group mygroup` should definitely fail, as now sphinx is involved + uv_snapshot!(context.filters(), context.sync() + .arg("--group").arg("mygroup"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × No solution found when resolving dependencies for split (python_full_version == '3.8.*'): + ╰─▶ Because the requested Python version (>=3.8) does not satisfy Python>=3.9 and sphinx==7.2.6 depends on Python>=3.9, we can conclude that sphinx==7.2.6 cannot be used. + And because only sphinx<=7.2.6 is available, we can conclude that sphinx>=7.2.6 cannot be used. + And because pharaohs-tomp:mygroup depends on sphinx>=7.2.6 and your project requires pharaohs-tomp:mygroup, we can conclude that your project's requirements are unsatisfiable. + + hint: The `requires-python` value (>=3.8) includes Python versions that are not supported by your dependencies (e.g., sphinx==7.2.6 only supports >=3.9). Consider using a more restrictive `requires-python` value (like >=3.9). + "); + + // Adding group requires python should fix it + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "pharaohs-tomp" + version = "0.1.0" + requires-python = ">=3.8" + dependencies = ["anyio"] + + [dependency-groups] + mygroup = ["sphinx>=7.2.6"] + + [tool.uv.dependency-groups] + mygroup = {requires-python = ">=3.9"} + "#, + )?; + + // Running `uv sync` should succeed, locking for the previous picked Python 3.8. + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 29 packages in [TIME] + Prepared 5 packages in [TIME] + Installed 5 packages in [TIME] + + anyio==4.3.0 + + exceptiongroup==1.2.0 + + idna==3.6 + + sniffio==1.3.1 + + typing-extensions==4.10.0 + "); + + // Running `uv sync --group mygroup` should pass, bumping the interpreter to 3.9, + // as the group requires-python saves us + uv_snapshot!(context.filters(), context.sync() + .arg("--group").arg("mygroup"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.9.[X] interpreter at: [PYTHON-3.9] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 29 packages in [TIME] + Prepared 22 packages in [TIME] + Installed 27 packages in [TIME] + + alabaster==0.7.16 + + anyio==4.3.0 + + babel==2.14.0 + + certifi==2024.2.2 + + charset-normalizer==3.3.2 + + docutils==0.20.1 + + exceptiongroup==1.2.0 + + idna==3.6 + + imagesize==1.4.1 + + importlib-metadata==7.1.0 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + packaging==24.0 + + pygments==2.17.2 + + requests==2.31.0 + + sniffio==1.3.1 + + snowballstemmer==2.2.0 + + sphinx==7.2.6 + + sphinxcontrib-applehelp==1.0.8 + + sphinxcontrib-devhelp==1.0.6 + + sphinxcontrib-htmlhelp==2.0.5 + + sphinxcontrib-jsmath==1.0.1 + + sphinxcontrib-qthelp==1.0.7 + + sphinxcontrib-serializinghtml==1.1.10 + + typing-extensions==4.10.0 + + urllib3==2.2.1 + + zipp==3.18.1 "); Ok(()) @@ -4080,17 +4369,17 @@ fn sync_custom_environment_path() -> Result<()> { // But if it's just an incompatible virtual environment... fs_err::remove_dir_all(context.temp_dir.join("foo"))?; - uv_snapshot!(context.filters(), context.venv().arg("foo").arg("--python").arg("3.11"), @r###" + uv_snapshot!(context.filters(), context.venv().arg("foo").arg("--python").arg("3.11"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] - warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` + warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`) Creating virtual environment at: foo Activate with: source foo/[BIN]/activate - "###); + "); // Even with some extraneous content... fs_err::write(context.temp_dir.join("foo").join("file"), b"")?; @@ -5817,17 +6106,17 @@ fn sync_invalid_environment() -> Result<()> { // But if it's just an incompatible virtual environment... fs_err::remove_dir_all(context.temp_dir.join(".venv"))?; - uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r###" + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] - warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` + warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`) Creating virtual environment at: .venv Activate with: source .venv/[BIN]/activate - "###); + "); // Even with some extraneous content... fs_err::write(context.temp_dir.join(".venv").join("file"), b"")?; @@ -5884,17 +6173,17 @@ fn sync_invalid_environment() -> Result<()> { // But if it's not a virtual environment... fs_err::remove_dir_all(context.temp_dir.join(".venv"))?; - uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r###" + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] - warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` + warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`) Creating virtual environment at: .venv Activate with: source .venv/[BIN]/activate - "###); + "); // Which we detect by the presence of a `pyvenv.cfg` file fs_err::remove_file(context.temp_dir.join(".venv").join("pyvenv.cfg"))?; @@ -6004,15 +6293,15 @@ fn sync_python_version() -> Result<()> { "###); // Unless explicitly requested... - uv_snapshot!(context.filters(), context.sync().arg("--python").arg("3.10"), @r###" + uv_snapshot!(context.filters(), context.sync().arg("--python").arg("3.10"), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- Using CPython 3.10.[X] interpreter at: [PYTHON-3.10] - error: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` - "###); + error: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` (from `project.requires-python`) + "); // But a pin should take precedence uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" @@ -6051,15 +6340,16 @@ fn sync_python_version() -> Result<()> { "###); // We should warn on subsequent uses, but respect the pinned version? - uv_snapshot!(context.filters(), context.sync(), @r###" + uv_snapshot!(context.filters(), context.sync(), @r" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- Using CPython 3.10.[X] interpreter at: [PYTHON-3.10] - error: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`. Use `uv python pin` to update the `.python-version` file to a compatible version. - "###); + error: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` (from `project.requires-python`) + Use `uv python pin` to update the `.python-version` file to a compatible version + "); // Unless the pin file is outside the project, in which case we should just ignore it entirely let child_dir = context.temp_dir.child("child"); @@ -8935,52 +9225,52 @@ fn transitive_group_conflicts_cycle() -> Result<()> { uv_snapshot!(context.filters(), context.sync(), @r" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `example @ file://[TEMP_DIR]/` - ╰─▶ Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: Project `example` has malformed dependency groups + Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); uv_snapshot!(context.filters(), context.sync().arg("--group").arg("dev"), @r" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `example @ file://[TEMP_DIR]/` - ╰─▶ Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: Project `example` has malformed dependency groups + Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); uv_snapshot!(context.filters(), context.sync().arg("--group").arg("dev").arg("--group").arg("test"), @r" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `example @ file://[TEMP_DIR]/` - ╰─▶ Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: Project `example` has malformed dependency groups + Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); uv_snapshot!(context.filters(), context.sync().arg("--group").arg("test").arg("--group").arg("magic"), @r" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `example @ file://[TEMP_DIR]/` - ╰─▶ Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: Project `example` has malformed dependency groups + Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); uv_snapshot!(context.filters(), context.sync().arg("--group").arg("dev").arg("--group").arg("magic"), @r" success: false - exit_code: 1 + exit_code: 2 ----- stdout ----- ----- stderr ----- - × Failed to build `example @ file://[TEMP_DIR]/` - ╰─▶ Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: Project `example` has malformed dependency groups + Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); Ok(()) diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 1d9eb5721..bc35f9490 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -475,17 +475,195 @@ fn create_venv_respects_pyproject_requires_python() -> Result<()> { context.venv.assert(predicates::path::is_dir()); // We warn if we receive an incompatible version - uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r###" + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] - warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` + warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`) Creating virtual environment at: .venv Activate with: source .venv/[BIN]/activate - "### + " + ); + + Ok(()) +} + +#[test] +fn create_venv_respects_group_requires_python() -> Result<()> { + let context = TestContext::new_with_versions(&["3.9", "3.10", "3.11", "3.12"]); + + // Without a Python requirement, we use the first on the PATH + uv_snapshot!(context.filters(), context.venv(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.9.[X] interpreter at: [PYTHON-3.9] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + " + ); + + // With `requires-python = ">=3.10"` on the default group, we pick 3.10 + // However non-default groups should not be consulted! + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + dependencies = [] + + [dependency-groups] + dev = ["sortedcontainers"] + other = ["sniffio"] + + [tool.uv.dependency-groups] + dev = {requires-python = ">=3.10"} + other = {requires-python = ">=3.12"} + "# + })?; + + uv_snapshot!(context.filters(), context.venv(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.10.[X] interpreter at: [PYTHON-3.10] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + " + ); + + // When the top-level requires-python and default group requires-python + // both apply, their intersection is used. However non-default groups + // should not be consulted! (here the top-level wins) + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11" + dependencies = [] + + [dependency-groups] + dev = ["sortedcontainers"] + other = ["sniffio"] + + [tool.uv.dependency-groups] + dev = {requires-python = ">=3.10"} + other = {requires-python = ">=3.12"} + "# + })?; + + uv_snapshot!(context.filters(), context.venv(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + " + ); + + // When the top-level requires-python and default group requires-python + // both apply, their intersection is used. However non-default groups + // should not be consulted! (here the group wins) + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.10" + dependencies = [] + + [dependency-groups] + dev = ["sortedcontainers"] + other = ["sniffio"] + + [tool.uv.dependency-groups] + dev = {requires-python = ">=3.11"} + other = {requires-python = ">=3.12"} + "# + })?; + + uv_snapshot!(context.filters(), context.venv(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + " + ); + + // We warn if we receive an incompatible version + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + dependencies = [] + + [dependency-groups] + dev = ["sortedcontainers"] + + [tool.uv.dependency-groups] + dev = {requires-python = ">=3.12"} + "# + })?; + + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `tool.uv.dependency-groups.dev.requires-python`). + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + " + ); + + // We error if there's no compatible version + // non-default groups are not consulted here! + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = "<3.12" + dependencies = [] + + [dependency-groups] + dev = ["sortedcontainers"] + other = ["sniffio"] + + [tool.uv.dependency-groups] + dev = {requires-python = ">=3.12"} + other = {requires-python = ">=3.11"} + "# + })?; + + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Found conflicting Python requirements: + │ - foo: <3.12 + │ - foo:dev: >=3.12 + " ); Ok(()) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index c203bcc71..f681690f4 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -127,6 +127,31 @@ default-groups = ["docs"] --- +### [`dependency-groups`](#dependency-groups) {: #dependency-groups } + +Additional settings for `dependency-groups`. + +Currently this can only be used to add `requires-python` constraints +to dependency groups (typically to inform uv that your dev tooling +has a higher python requirement than your actual project). + +This cannot be used to define dependency groups, use the top-level +`[dependency-groups]` table for that. + +**Default value**: `[]` + +**Type**: `dict` + +**Example usage**: + +```toml title="pyproject.toml" + +[tool.uv.dependency-groups] +my-group = {requires-python = ">=3.12"} +``` + +--- + ### [`dev-dependencies`](#dev-dependencies) {: #dev-dependencies } The project's development dependencies. diff --git a/uv.schema.json b/uv.schema.json index 97fe9e28e..33c1ff1f5 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -151,6 +151,17 @@ } ] }, + "dependency-groups": { + "description": "Additional settings for `dependency-groups`.\n\nCurrently this can only be used to add `requires-python` constraints to dependency groups (typically to inform uv that your dev tooling has a higher python requirement than your actual project).\n\nThis cannot be used to define dependency groups, use the top-level `[dependency-groups]` table for that.", + "anyOf": [ + { + "$ref": "#/definitions/ToolUvDependencyGroups" + }, + { + "type": "null" + } + ] + }, "dependency-metadata": { "description": "Pre-defined static metadata for dependencies of the project (direct or transitive). When provided, enables the resolver to use the specified metadata instead of querying the registry or building the relevant package from source.\n\nMetadata should be provided in adherence with the [Metadata 2.3](https://packaging.python.org/en/latest/specifications/core-metadata/) standard, though only the following fields are respected:\n\n- `name`: The name of the package. - (Optional) `version`: The version of the package. If omitted, the metadata will be applied to all versions of the package. - (Optional) `requires-dist`: The dependencies of the package (e.g., `werkzeug>=0.14`). - (Optional) `requires-python`: The Python version required by the package (e.g., `>=3.10`). - (Optional) `provides-extras`: The extras provided by the package.", "type": [ @@ -824,6 +835,18 @@ } ] }, + "DependencyGroupSettings": { + "type": "object", + "properties": { + "requires-python": { + "description": "Version of python to require when installing this group", + "type": [ + "string", + "null" + ] + } + } + }, "ExcludeNewer": { "description": "Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`).", "type": "string", @@ -2344,6 +2367,12 @@ } ] }, + "ToolUvDependencyGroups": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/DependencyGroupSettings" + } + }, "ToolUvSources": { "type": "object", "additionalProperties": {