From 7783c3c4a8a1cb038398a39916f2ad3f1221f8e3 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 30 May 2025 10:22:26 -0400 Subject: [PATCH 01/23] move RequiresPython to uv-distribution-types --- crates/uv-bench/benches/uv.rs | 6 ++-- crates/uv-distribution-types/src/lib.rs | 2 ++ .../src/requires_python.rs | 33 +++++++++---------- crates/uv-resolver/src/lib.rs | 2 -- .../src/lock/export/pylock_toml.rs | 6 ++-- crates/uv-resolver/src/lock/mod.rs | 8 ++--- crates/uv-resolver/src/marker.rs | 2 +- crates/uv-resolver/src/pubgrub/report.rs | 6 ++-- crates/uv-resolver/src/python_requirement.rs | 3 +- crates/uv-resolver/src/resolution/output.rs | 7 ++-- .../src/resolution/requirements_txt.rs | 10 +++--- .../uv-resolver/src/resolver/environment.rs | 6 ++-- crates/uv-resolver/src/resolver/provider.rs | 3 +- crates/uv-resolver/src/version_map.rs | 5 +-- crates/uv-workspace/src/pyproject.rs | 1 - crates/uv/src/commands/build_frontend.rs | 6 ++-- crates/uv/src/commands/pip/compile.rs | 6 ++-- crates/uv/src/commands/pip/latest.rs | 4 +-- crates/uv/src/commands/pip/list.rs | 6 ++-- crates/uv/src/commands/pip/tree.rs | 4 +-- crates/uv/src/commands/project/init.rs | 2 +- crates/uv/src/commands/project/lock.rs | 4 +-- crates/uv/src/commands/project/lock_target.rs | 4 +-- crates/uv/src/commands/project/mod.rs | 7 ++-- 24 files changed, 70 insertions(+), 73 deletions(-) rename crates/{uv-resolver => uv-distribution-types}/src/requires_python.rs (97%) diff --git a/crates/uv-bench/benches/uv.rs b/crates/uv-bench/benches/uv.rs index 95106a52b0a9b..9bdd7adb92377 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-distribution-types/src/lib.rs b/crates/uv-distribution-types/src/lib.rs index 44030ffeee350..1e3ad7eba3cb4 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 8e4d33213a4c1..ae9fee7fefa82 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-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 3285f9a6adf25..48904660d5d08 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 4f3e885abb56b..d2c2383a5ff6d 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 faacae736c594..47597f2ec6e5a 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 1bb938a339992..b63d5140105fa 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 b7b83a19b2752..91f8d4baaf752 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 178b77866018b..0dce9b4f76406 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 5df5ae6c37b5c..928b9c605e341 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 5ad6480c2f97b..bcdef207be39a 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 354941886592b..6e816f9911ce3 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 378d2a9eb61d3..d6384e3e29117 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 44e70e73b8df4..8a0b17fc41c66 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-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 2b0e44c162d12..6894d48b8ecb8 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -652,7 +652,6 @@ impl<'de> serde::de::Deserialize<'de> for ToolUvSources { deserializer.deserialize_map(SourcesVisitor) } } - #[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/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index c601541dae7f1..c5183f9345faa 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -22,7 +22,9 @@ 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 +35,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}; diff --git a/crates/uv/src/commands/pip/compile.rs b/crates/uv/src/commands/pip/compile.rs index 8da16ef4663c0..20a60416f5a8f 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 ac3ce7d1fa50f..25da8466c5f71 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 48786d86c74a2..824c9db2bb405 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 05290ffd07fd7..aed3640689ea8 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/init.rs b/crates/uv/src/commands/project/init.rs index 376e8e007af40..9f7d69fd89567 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -4,6 +4,7 @@ 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; @@ -21,7 +22,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; diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 89b3713ccff7a..9f020733633c5 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -18,7 +18,7 @@ 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 +28,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}; diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index cb45aa8ec6810..8639114bca4d6 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -5,11 +5,11 @@ use itertools::Either; use uv_configuration::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::{Workspace, WorkspaceMember}; diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index d2efc3ccde808..2fc4f032d77f4 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; From 4979f0914de157cdba1d40e0e50a1491a60349b4 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Wed, 19 Mar 2025 13:05:42 -0400 Subject: [PATCH 02/23] Add `[tool.uv.dependency-groups].mygroup.requires-python` --- .../uv-distribution/src/metadata/lowering.rs | 20 ++++- crates/uv-settings/src/settings.rs | 6 ++ crates/uv-workspace/src/pyproject.rs | 83 +++++++++++++++++++ crates/uv-workspace/src/workspace.rs | 6 ++ crates/uv/tests/it/show_settings.rs | 2 +- 5 files changed, 114 insertions(+), 3 deletions(-) diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index dd0974a99f99e..4d004c47c5626 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -8,6 +8,7 @@ use thiserror::Error; use uv_distribution_filename::DistExtension; use uv_distribution_types::{ Index, IndexLocations, IndexMetadata, IndexName, Origin, Requirement, RequirementSource, + RequiresPython, }; use uv_git_types::{GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; @@ -16,7 +17,7 @@ use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository use uv_pypi_types::{ConflictItem, ParsedUrlError, VerbatimParsedUrl}; use uv_redacted::DisplaySafeUrl; use uv_workspace::Workspace; -use uv_workspace::pyproject::{PyProjectToml, Source, Sources}; +use uv_workspace::pyproject::{DependencyGroupSettings, PyProjectToml, Source, Sources}; use crate::metadata::GitWorkspaceMember; @@ -34,7 +35,7 @@ enum RequirementOrigin { impl LoweredRequirement { /// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`. pub(crate) fn from_requirement<'data>( - requirement: uv_pep508::Requirement, + mut requirement: uv_pep508::Requirement, project_name: Option<&'data PackageName>, project_dir: &'data Path, project_sources: &'data BTreeMap, @@ -45,6 +46,21 @@ impl LoweredRequirement { workspace: &'data Workspace, git_member: Option<&'data GitWorkspaceMember<'data>>, ) -> impl Iterator> + use<'data> + 'data { + let empty_settings = DependencyGroupSettings::default(); + let group_settings = workspace + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.dependency_groups.as_ref()) + .and_then(|settings| settings.inner().get(group?)) + .unwrap_or(&empty_settings); + + if let Some(python_versions) = &group_settings.requires_python { + let extra_markers = RequiresPython::from_specifiers(python_versions).to_marker_tree(); + requirement.marker.and(extra_markers); + } + // Identify the source from the `tool.uv.sources` table. let (sources, origin) = if let Some(source) = project_sources.get(&requirement.name) { (Some(source), RequirementOrigin::Project) diff --git a/crates/uv-settings/src/settings.rs b/crates/uv-settings/src/settings.rs index ff6d3999575da..2c18fb40aaf5a 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/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 6894d48b8ecb8..5b1dc165e1319 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -353,6 +353,17 @@ pub struct ToolUv { )] pub default_groups: Option, + /// Additional settings for `dependency-groups` + #[option( + default = "[]", + value_type = "dict", + example = r#" + [tool.uv.dependency-groups.mygroup] + 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 @@ -652,6 +663,78 @@ impl<'de> serde::de::Deserialize<'de> for ToolUvSources { deserializer.deserialize_map(SourcesVisitor) } } + +#[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 3caaa8f8cb7a1..a18b3510ff92b 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1818,6 +1818,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -1913,6 +1914,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2123,6 +2125,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2230,6 +2233,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2350,6 +2354,7 @@ mod tests { "managed": null, "package": null, "default-groups": null, + "dependency-groups": null, "dev-dependencies": null, "override-dependencies": null, "constraint-dependencies": null, @@ -2444,6 +2449,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/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 87453090c2c01..7635bd5234c55 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` " ); From be4c26957f96e6ef3dbbf37b96d346ed89b6aee1 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 30 May 2025 12:27:02 -0400 Subject: [PATCH 03/23] add test --- crates/uv/tests/it/lock.rs | 112 +++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 4387d348afe8e..1773f595317a3 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -20660,6 +20660,118 @@ 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"] + "#, + )?; + + 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" + + [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" }, + { name = "sortedcontainers" }, + ] + foo = [ + { name = "idna" }, + ] + + [package.metadata] + requires-dist = [{ name = "typing-extensions" }] + + [package.metadata.requires-dev] + bar = [ + { name = "sniffio" }, + { name = "sortedcontainers" }, + ] + 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_include_cycle() -> Result<()> { let context = TestContext::new("3.12"); From 28ffe84db57aef260d02f17d1296b309de344503 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 30 May 2025 12:31:25 -0400 Subject: [PATCH 04/23] add more test --- crates/uv/tests/it/lock.rs | 140 ++++++++++++++++++++++++++++++++++++- 1 file changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 1773f595317a3..49dc3defb4dc7 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -20676,6 +20676,9 @@ fn lock_group_requires_python() -> Result<()> { [dependency-groups] foo = ["idna"] bar = ["sortedcontainers", "sniffio"] + + [tool.uv.dependency-groups] + bar = { requires-python = ">=3.13" } "#, )?; @@ -20698,6 +20701,10 @@ fn lock_group_requires_python() -> Result<()> { 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" @@ -20721,11 +20728,132 @@ fn lock_group_requires_python() -> Result<()> { [package.dev-dependencies] bar = [ - { name = "sniffio" }, - { name = "sortedcontainers" }, + { 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"] + + [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" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, ] [package.metadata] @@ -20733,10 +20861,14 @@ fn lock_group_requires_python() -> Result<()> { [package.metadata.requires-dev] bar = [ + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, + ] + foo = [ + { name = "idna" }, { name = "sniffio" }, { name = "sortedcontainers" }, ] - foo = [{ name = "idna" }] [[package]] name = "sniffio" @@ -20772,6 +20904,8 @@ fn lock_group_requires_python() -> Result<()> { } + + #[test] fn lock_group_include_cycle() -> Result<()> { let context = TestContext::new("3.12"); From 5299624daafce87f0e580f725b4ed128c5a92475 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 30 May 2025 13:07:40 -0400 Subject: [PATCH 05/23] apply settings pre-flatening --- .../uv-distribution/src/metadata/lowering.rs | 20 +-------- .../src/metadata/requires_dist.rs | 21 ++++++++-- crates/uv-workspace/src/dependency_groups.rs | 22 +++++++++- crates/uv-workspace/src/workspace.rs | 21 ++++++++-- crates/uv/tests/it/lock.rs | 42 ++++++++++++++----- 5 files changed, 88 insertions(+), 38 deletions(-) diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 4d004c47c5626..dd0974a99f99e 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -8,7 +8,6 @@ use thiserror::Error; use uv_distribution_filename::DistExtension; use uv_distribution_types::{ Index, IndexLocations, IndexMetadata, IndexName, Origin, Requirement, RequirementSource, - RequiresPython, }; use uv_git_types::{GitReference, GitUrl, GitUrlParseError}; use uv_normalize::{ExtraName, GroupName, PackageName}; @@ -17,7 +16,7 @@ use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository use uv_pypi_types::{ConflictItem, ParsedUrlError, VerbatimParsedUrl}; use uv_redacted::DisplaySafeUrl; use uv_workspace::Workspace; -use uv_workspace::pyproject::{DependencyGroupSettings, PyProjectToml, Source, Sources}; +use uv_workspace::pyproject::{PyProjectToml, Source, Sources}; use crate::metadata::GitWorkspaceMember; @@ -35,7 +34,7 @@ enum RequirementOrigin { impl LoweredRequirement { /// Combine `project.dependencies` or `project.optional-dependencies` with `tool.uv.sources`. pub(crate) fn from_requirement<'data>( - mut requirement: uv_pep508::Requirement, + requirement: uv_pep508::Requirement, project_name: Option<&'data PackageName>, project_dir: &'data Path, project_sources: &'data BTreeMap, @@ -46,21 +45,6 @@ impl LoweredRequirement { workspace: &'data Workspace, git_member: Option<&'data GitWorkspaceMember<'data>>, ) -> impl Iterator> + use<'data> + 'data { - let empty_settings = DependencyGroupSettings::default(); - let group_settings = workspace - .pyproject_toml() - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.dependency_groups.as_ref()) - .and_then(|settings| settings.inner().get(group?)) - .unwrap_or(&empty_settings); - - if let Some(python_versions) = &group_settings.requires_python { - let extra_markers = RequiresPython::from_specifiers(python_versions).to_marker_tree(); - requirement.marker.and(extra_markers); - } - // Identify the source from the `tool.uv.sources` table. let (sources, origin) = if let Some(source) = project_sources.get(&requirement.name) { (Some(source), RequirementOrigin::Project) diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index d728ed58b4cef..85eb699c0e32d 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -9,7 +9,7 @@ use uv_distribution_types::{IndexLocations, Requirement}; use uv_normalize::{DEV_DEPENDENCIES, ExtraName, GroupName, PackageName}; use uv_pep508::MarkerTree; use uv_workspace::dependency_groups::FlatDependencyGroups; -use uv_workspace::pyproject::{Sources, ToolUvSources}; +use uv_workspace::pyproject::{Sources, ToolUvDependencyGroups, ToolUvSources}; use uv_workspace::{DiscoveryOptions, MemberDiscovery, ProjectWorkspace, WorkspaceCache}; use crate::Metadata; @@ -127,10 +127,23 @@ impl RequiresDist { .flatten() .collect::>(); + // Get additional settings + let empty_settings = ToolUvDependencyGroups::default(); + let group_settings = project_workspace + .current_project() + .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) - .map_err(|err| err.with_dev_dependencies(dev_dependencies))?; + let mut dependency_groups = FlatDependencyGroups::from_dependency_groups( + &dependency_groups, + group_settings.inner(), + ) + .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 { diff --git a/crates/uv-workspace/src/dependency_groups.rs b/crates/uv-workspace/src/dependency_groups.rs index e6964544ac72b..63aa493d3ed20 100644 --- a/crates/uv-workspace/src/dependency_groups.rs +++ b/crates/uv-workspace/src/dependency_groups.rs @@ -5,10 +5,13 @@ use std::str::FromStr; use thiserror::Error; use tracing::error; +use uv_distribution_types::RequiresPython; use uv_normalize::{DEV_DEPENDENCIES, GroupName}; use uv_pep508::Pep508Error; use uv_pypi_types::{DependencyGroupSpecifier, VerbatimParsedUrl}; +use crate::pyproject::DependencyGroupSettings; + /// PEP 735 dependency groups, with any `include-group` entries resolved. #[derive(Debug, Default, Clone)] pub struct FlatDependencyGroups( @@ -20,10 +23,12 @@ impl FlatDependencyGroups { /// lists of requirements. pub fn from_dependency_groups( groups: &BTreeMap<&GroupName, &Vec>, + settings: &BTreeMap, ) -> Result { fn resolve_group<'data>( resolved: &mut BTreeMap>>, groups: &'data BTreeMap<&GroupName, &Vec>, + settings: &BTreeMap, name: &'data GroupName, parents: &mut Vec<&'data GroupName>, ) -> Result<(), DependencyGroupError> { @@ -69,7 +74,7 @@ impl FlatDependencyGroups { } } DependencyGroupSpecifier::IncludeGroup { include_group } => { - resolve_group(resolved, groups, include_group, parents)?; + resolve_group(resolved, groups, settings, include_group, parents)?; requirements .extend(resolved.get(include_group).into_iter().flatten().cloned()); } @@ -81,6 +86,19 @@ impl FlatDependencyGroups { } } } + + let empty_settings = DependencyGroupSettings::default(); + let DependencyGroupSettings { requires_python } = + settings.get(name).unwrap_or(&empty_settings); + // requires-python should get splatted onto everything + if let Some(requires_python) = requires_python { + 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); @@ -90,7 +108,7 @@ impl FlatDependencyGroups { 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)) } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index a18b3510ff92b..a2f7ab3727b22 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -19,7 +19,8 @@ use uv_warnings::warn_user_once; use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroups}; use crate::pyproject::{ - Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources, ToolUvWorkspace, + Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvDependencyGroups, ToolUvSources, + ToolUvWorkspace, }; type WorkspaceMembers = Arc>; @@ -471,10 +472,22 @@ impl Workspace { .flatten() .collect::>(); + // Get additional settings + let empty_settings = ToolUvDependencyGroups::default(); + let group_settings = self + .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) - .map_err(|err| err.with_dev_dependencies(dev_dependencies))?; + let mut dependency_groups = FlatDependencyGroups::from_dependency_groups( + &dependency_groups, + group_settings.inner(), + ) + .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 { diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 49dc3defb4dc7..e75822cfbe783 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -20778,7 +20778,6 @@ fn lock_group_requires_python() -> Result<()> { Ok(()) } - #[test] fn lock_group_includes_requires_python() -> Result<()> { let context = TestContext::new("3.12"); @@ -20795,9 +20794,13 @@ fn lock_group_includes_requires_python() -> Result<()> { [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" } "#, )?; @@ -20821,8 +20824,10 @@ fn lock_group_includes_requires_python() -> Result<()> { revision = 2 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.13'", - "python_full_version < '3.13'", + "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] @@ -20850,10 +20855,20 @@ fn lock_group_includes_requires_python() -> Result<()> { { 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" }, - { name = "sortedcontainers" }, + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, ] [package.metadata] @@ -20864,10 +20879,20 @@ fn lock_group_includes_requires_python() -> Result<()> { { 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" }, - { name = "sortedcontainers" }, + { name = "sniffio", marker = "python_full_version >= '3.13'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.13'" }, ] [[package]] @@ -20903,9 +20928,6 @@ fn lock_group_includes_requires_python() -> Result<()> { Ok(()) } - - - #[test] fn lock_group_include_cycle() -> Result<()> { let context = TestContext::new("3.12"); From aa529594c0353ea01c60fd20a485404318e8572a Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 30 May 2025 13:11:28 -0400 Subject: [PATCH 06/23] add test of transitive contradiction --- crates/uv/tests/it/lock.rs | 124 +++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index e75822cfbe783..7788bc5f78a62 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -20928,6 +20928,130 @@ fn lock_group_includes_requires_python() -> Result<()> { 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"); From 815c30e8de4f0ff6e9be78d8fbe1f560601239e9 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 30 May 2025 13:43:01 -0400 Subject: [PATCH 07/23] regen --- docs/reference/settings.md | 18 ++++++++++++++++++ uv.schema.json | 29 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 54fabe83bb246..6c63af092fbec 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -127,6 +127,24 @@ default-groups = ["docs"] --- +### [`dependency-groups`](#dependency-groups) {: #dependency-groups } + +Additional settings for `dependency-groups` + +**Default value**: `[]` + +**Type**: `dict` + +**Example usage**: + +```toml title="pyproject.toml" + +[tool.uv.dependency-groups.mygroup] +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 d2ca69f20998d..a32db83dbf322 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -151,6 +151,17 @@ } ] }, + "dependency-groups": { + "description": "Additional settings for `dependency-groups`", + "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": [ @@ -823,6 +834,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", @@ -2348,6 +2371,12 @@ } ] }, + "ToolUvDependencyGroups": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/DependencyGroupSettings" + } + }, "ToolUvSources": { "type": "object", "additionalProperties": { From 71c7b6e8a7870de7c8746adffe259d6885734ee6 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 30 May 2025 13:43:40 -0400 Subject: [PATCH 08/23] fmt --- crates/uv/tests/it/lock.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 7788bc5f78a62..9d6065373642d 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -21051,7 +21051,6 @@ fn lock_group_includes_requires_python_contradiction() -> Result<()> { Ok(()) } - #[test] fn lock_group_include_cycle() -> Result<()> { let context = TestContext::new("3.12"); From 4f995fc15b45878faa65eef524fa4af7df93ea6a Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Thu, 5 Jun 2025 16:11:15 -0400 Subject: [PATCH 09/23] use enabled groups in interpretter discovery --- Cargo.lock | 1 + .../uv-configuration/src/dependency_groups.rs | 8 + crates/uv-configuration/src/extras.rs | 8 + crates/uv-workspace/Cargo.toml | 1 + crates/uv-workspace/src/workspace.rs | 44 +++++- crates/uv/src/commands/build_frontend.rs | 6 +- crates/uv/src/commands/project/add.rs | 88 ++++++----- crates/uv/src/commands/project/export.rs | 13 +- crates/uv/src/commands/project/init.rs | 5 +- crates/uv/src/commands/project/lock.rs | 5 +- crates/uv/src/commands/project/lock_target.rs | 8 +- crates/uv/src/commands/project/mod.rs | 40 +++-- crates/uv/src/commands/project/remove.rs | 22 +-- crates/uv/src/commands/project/run.rs | 32 ++-- crates/uv/src/commands/project/sync.rs | 21 +-- crates/uv/src/commands/project/tree.rs | 10 +- crates/uv/src/commands/project/version.rs | 28 ++-- crates/uv/src/commands/python/find.rs | 4 + crates/uv/src/commands/python/pin.rs | 9 +- crates/uv/src/commands/venv.rs | 15 +- crates/uv/src/lib.rs | 8 +- crates/uv/src/settings.rs | 16 +- crates/uv/tests/it/venv.rs | 138 ++++++++++++++++++ 23 files changed, 392 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ecb69cc3a98c9..7e47afee21c77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6013,6 +6013,7 @@ dependencies = [ "tracing", "uv-build-backend", "uv-cache-key", + "uv-configuration", "uv-distribution-types", "uv-fs", "uv-git-types", diff --git a/crates/uv-configuration/src/dependency_groups.rs b/crates/uv-configuration/src/dependency_groups.rs index 345f4077c3bb8..b7d010835d9af 100644 --- a/crates/uv-configuration/src/dependency_groups.rs +++ b/crates/uv-configuration/src/dependency_groups.rs @@ -295,6 +295,14 @@ 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 3bc9da21a84b1..e39fc72efab73 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-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index a8d672aabfde3..941009e823eff 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -44,6 +44,7 @@ tokio = { workspace = true } toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true } +uv-configuration.workspace = true [dev-dependencies] anyhow = { workspace = true } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index a2f7ab3727b22..73315cc4f19da 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}; @@ -414,14 +415,49 @@ 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, + ) -> impl Iterator { + self.packages().iter().flat_map(move |(name, member)| { + // 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, requires_python)); + + // Get the requires-python for each enabled group on this package + let group_requires = member + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.dependency_groups.as_ref()) + .map(move |settings| { + settings + .inner() + .iter() + .filter_map(move |(group_name, settings)| { + if groups.contains(group_name) { + settings + .requires_python + .as_ref() + .map(|requires_python| (name, requires_python)) + } else { + None + } + }) + }) + .into_iter() + .flatten(); + + top_requires.into_iter().chain(group_requires) }) } diff --git a/crates/uv/src/commands/build_frontend.rs b/crates/uv/src/commands/build_frontend.rs index c5183f9345faa..dd174ca067037 100644 --- a/crates/uv/src/commands/build_frontend.rs +++ b/crates/uv/src/commands/build_frontend.rs @@ -16,7 +16,8 @@ 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::{ @@ -473,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/project/add.rs b/crates/uv/src/commands/project/add.rs index a4091504db5e1..78664e4b268f2 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, interpretter 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 566f4af411cba..ac228989c6bca 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 9f7d69fd89567..71aacdc1bb1df 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -11,7 +11,8 @@ 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; @@ -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/lock.rs b/crates/uv/src/commands/project/lock.rs index 9f020733633c5..e662177d9f17b 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -12,7 +12,8 @@ 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; @@ -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 interpretter discovery + &DependencyGroupsWithDefaults::none(), python.as_deref().map(PythonRequest::parse), &network_settings, python_preference, diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index 8639114bca4d6..e8c6a696d8300 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -3,7 +3,7 @@ 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, RequiresPython}; use uv_normalize::{GroupName, PackageName}; @@ -219,7 +219,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) => { + // TODO(Gankra): I'm not sure if this should be none of the groups or *all* + 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 2fc4f032d77f4..6665cb487772e 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -287,7 +287,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 { @@ -339,7 +339,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!(), @@ -359,7 +359,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)") } @@ -430,20 +430,21 @@ impl PlatformState { #[allow(clippy::result_large_err)] pub(crate) fn find_requires_python( workspace: &Workspace, + groups: &DependencyGroupsWithDefaults, ) -> Result, ProjectError> { // If there are no `Requires-Python` specifiers in the workspace, return `None`. - if workspace.requires_python().next().is_none() { + if workspace.requires_python(groups).next().is_none() { return Ok(None); } match RequiresPython::intersection( workspace - .requires_python() + .requires_python(groups) .map(|(.., specifiers)| specifiers), ) { Some(requires_python) => Ok(Some(requires_python)), None => Err(ProjectError::DisjointRequiresPython( workspace - .requires_python() + .requires_python(groups) .map(|(name, specifiers)| (name.clone(), specifiers.clone())) .collect(), )), @@ -875,6 +876,7 @@ impl ProjectInterpreter { pub(crate) async fn discover( workspace: &Workspace, project_dir: &Path, + groups: &DependencyGroupsWithDefaults, python_request: Option, network_settings: &NetworkSettings, python_preference: PythonPreference, @@ -891,8 +893,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); @@ -1082,10 +1090,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); @@ -1166,6 +1178,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, ) @@ -1232,6 +1246,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, @@ -1250,6 +1265,7 @@ impl ProjectEnvironment { match ProjectInterpreter::discover( workspace, workspace.install_path().as_ref(), + groups, python, network_settings, python_preference, @@ -2435,7 +2451,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 @@ -2454,7 +2470,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()); @@ -2464,7 +2480,7 @@ pub(crate) fn detect_conflicts( return Err(ProjectError::Conflict(ConflictError { set: set.clone(), conflicts, - dev: dev.clone(), + groups: groups.clone(), })); } } diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 6dab60012d8cf..d17cd88edb352 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 035528b1ccfb2..97b5001bc7ae7 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, ) @@ -647,6 +653,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 +684,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 +768,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 ed96795e571ee..940b3a653c049 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 6bf57d1a74b20..9c42a8a8604cf 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 0e50c2ac0c2e6..6f911a211179d 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,21 @@ async fn lock_and_sync( return Ok(ExitStatus::Success); } + // Determine the groups and extras that should be enabled. + // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? + 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 +413,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 +477,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 +496,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 63e25fed13235..4caa7bceb69d9 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, ) diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index e0b241bccfbb9..a0af7ec41a97d 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 c0cf039213991..ca0dd630545fc 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, ) diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index b7b1a78592c41..51041bcbc26e0 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 b5eb2f5d07b3c..f8d44b50ca679 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/venv.rs b/crates/uv/tests/it/venv.rs index 1d9eb5721b206..1bd6a95363a9d 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -491,6 +491,144 @@ fn create_venv_respects_pyproject_requires_python() -> Result<()> { Ok(()) } +#[test] +fn create_venv_respects_group_requires_python() -> Result<()> { + let context = TestContext::new_with_versions(&["3.11", "3.9", "3.10", "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.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "### + ); + + // With `requires-python = ">=3.10"` on the default group, we pick 3.10 + 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"] + + [tool.uv.dependency-groups] + dev = {requires-python = ">=3.10"} + "# + })?; + + 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. + 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"] + + [tool.uv.dependency-groups] + dev = {requires-python = ">=3.10"} + "# + })?; + + 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. + 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"] + + [tool.uv.dependency-groups] + dev = {requires-python = ">=3.11"} + "# + })?; + + 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` + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "### + ); + + Ok(()) +} + #[test] fn create_venv_ignores_missing_pyproject_metadata() -> Result<()> { let context = TestContext::new_with_versions(&["3.12"]); From ed2e780e9bcad5ecd8c2832028ff36149b12778e Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Mon, 9 Jun 2025 23:34:20 -0400 Subject: [PATCH 10/23] properly flatten dependency-group requires-pythons --- .../src/metadata/requires_dist.rs | 73 +---- crates/uv-workspace/src/dependency_groups.rs | 127 ++++++-- crates/uv-workspace/src/workspace.rs | 109 ++----- .../uv/src/commands/project/install_target.rs | 29 +- crates/uv/src/commands/project/lock.rs | 4 +- crates/uv/src/commands/project/lock_target.rs | 9 +- crates/uv/src/commands/project/mod.rs | 25 +- crates/uv/tests/it/lock.rs | 62 ++-- crates/uv/tests/it/run.rs | 280 ++++++++++++++++++ crates/uv/tests/it/sync.rs | 25 +- crates/uv/tests/it/venv.rs | 49 ++- 11 files changed, 545 insertions(+), 247 deletions(-) diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 85eb699c0e32d..436b40710871c 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -6,10 +6,10 @@ 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, ToolUvDependencyGroups, ToolUvSources}; +use uv_workspace::pyproject::{Sources, ToolUvSources}; use uv_workspace::{DiscoveryOptions, MemberDiscovery, ProjectWorkspace, WorkspaceCache}; use crate::Metadata; @@ -107,54 +107,9 @@ 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::>(); - - // Get additional settings - let empty_settings = ToolUvDependencyGroups::default(); - let group_settings = project_workspace - .current_project() - .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| 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().pyproject_toml(), + )?; // Now that we've resolved the dependency groups, we can validate that each source references // a valid extra or group, if present. @@ -163,9 +118,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(); @@ -195,9 +151,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)) }) @@ -278,7 +236,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(), @@ -286,7 +244,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-workspace/src/dependency_groups.rs b/crates/uv-workspace/src/dependency_groups.rs index 63aa493d3ed20..a3b38b1e396ed 100644 --- a/crates/uv-workspace/src/dependency_groups.rs +++ b/crates/uv-workspace/src/dependency_groups.rs @@ -7,26 +7,77 @@ use tracing::error; use uv_distribution_types::RequiresPython; use uv_normalize::{DEV_DEPENDENCIES, GroupName}; +use uv_pep440::VersionSpecifiers; use uv_pep508::Pep508Error; use uv_pypi_types::{DependencyGroupSpecifier, VerbatimParsedUrl}; -use crate::pyproject::DependencyGroupSettings; +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 { + pub fn from_pyproject_toml( + pyproject_toml: &PyProjectToml, + ) -> Result { + // Otherwise, return the dependency groups in the non-project workspace root. + // 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| 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(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>, settings: &BTreeMap, ) -> Result { fn resolve_group<'data>( - resolved: &mut BTreeMap>>, + resolved: &mut BTreeMap, groups: &'data BTreeMap<&GroupName, &Vec>, settings: &BTreeMap, name: &'data GroupName, @@ -59,6 +110,7 @@ impl FlatDependencyGroups { parents.push(name); let mut requirements = Vec::with_capacity(specifiers.len()); + let mut full_requires_python = None; for specifier in *specifiers { match specifier { DependencyGroupSpecifier::Requirement(requirement) => { @@ -75,8 +127,17 @@ impl FlatDependencyGroups { } DependencyGroupSpecifier::IncludeGroup { include_group } => { resolve_group(resolved, groups, settings, include_group, parents)?; - requirements - .extend(resolved.get(include_group).into_iter().flatten().cloned()); + if let Some(included) = resolved.get(include_group) { + requirements.extend(included.requirements.iter().cloned()); + let versions = full_requires_python + .into_iter() + .flatten() + .chain(included.requires_python.clone().into_iter().flatten()) + .collect::>(); + let new_requires_python = VersionSpecifiers::from_iter(versions); + full_requires_python = + (!new_requires_python.is_empty()).then_some(new_requires_python); + } } DependencyGroupSpecifier::Object(map) => { return Err(DependencyGroupError::DependencyObjectSpecifierNotSupported( @@ -90,8 +151,19 @@ impl FlatDependencyGroups { let empty_settings = DependencyGroupSettings::default(); let DependencyGroupSettings { requires_python } = settings.get(name).unwrap_or(&empty_settings); - // requires-python should get splatted onto everything if let Some(requires_python) = requires_python { + // Merge in this requires-python to the full flattened version + let versions = full_requires_python + .into_iter() + .flatten() + .chain(requires_python.clone()) + .collect::>(); + let new_requires_python = VersionSpecifiers::from_iter(versions); + full_requires_python = + (!new_requires_python.is_empty()).then_some(new_requires_python); + // Merge in this requires-python to every requirement of this group + // We don't need to merge the full_requires_python because included + // groups already applied theirs. for requirement in &mut requirements { let extra_markers = RequiresPython::from_specifiers(requires_python).to_marker_tree(); @@ -101,7 +173,13 @@ impl FlatDependencyGroups { parents.pop(); - resolved.insert(name.clone(), requirements); + resolved.insert( + name.clone(), + FlatDependencyGroup { + requirements, + requires_python: full_requires_python, + }, + ); Ok(()) } @@ -114,45 +192,30 @@ impl FlatDependencyGroups { } /// 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() diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 73315cc4f19da..25506f8d6eb4a 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -18,10 +18,9 @@ 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, ToolUvDependencyGroups, ToolUvSources, - ToolUvWorkspace, + Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources, ToolUvWorkspace, }; type WorkspaceMembers = Arc>; @@ -415,11 +414,14 @@ impl Workspace { } /// Returns an iterator over the `requires-python` values for each member of the workspace. + #[allow(clippy::type_complexity)] pub fn requires_python( &self, groups: &DependencyGroupsWithDefaults, - ) -> impl Iterator { - self.packages().iter().flat_map(move |(name, member)| { + ) -> Result, VersionSpecifiers)>, DependencyGroupError> + { + let mut requires = Vec::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 @@ -430,35 +432,28 @@ impl Workspace { .project .as_ref() .and_then(|project| project.requires_python.as_ref()) - .map(|requires_python| (name, requires_python)); + .map(|requires_python| (name, None, requires_python.clone())); + requires.extend(top_requires); // Get the requires-python for each enabled group on this package - let group_requires = member - .pyproject_toml() - .tool - .as_ref() - .and_then(|tool| tool.uv.as_ref()) - .and_then(|uv| uv.dependency_groups.as_ref()) - .map(move |settings| { - settings - .inner() - .iter() - .filter_map(move |(group_name, settings)| { - if groups.contains(group_name) { - settings - .requires_python - .as_ref() - .map(|requires_python| (name, requires_python)) - } else { - None - } - }) - }) - .into_iter() - .flatten(); - - top_requires.into_iter().chain(group_requires) - }) + // We need to do full flattening here because include-group can transfer requires-python + let dependency_groups = + FlatDependencyGroups::from_pyproject_toml(&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, 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 @@ -476,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() @@ -492,47 +484,8 @@ 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::>(); - - // Get additional settings - let empty_settings = ToolUvDependencyGroups::default(); - let group_settings = self - .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| 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.pyproject_toml)?; Ok(dependency_groups.into_inner()) } } diff --git a/crates/uv/src/commands/project/install_target.rs b/crates/uv/src/commands/project/install_target.rs index d225114c9e83b..b0f20e76fe738 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 e662177d9f17b..099ab1a850827 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -440,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 e8c6a696d8300..13c980530ca60 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -11,7 +11,7 @@ use uv_pep508::RequirementOrigin; use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl}; 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()), } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 6665cb487772e..8fa5451d5fa80 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -195,8 +195,14 @@ 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("The workspace contains conflicting Python requirements:\n{}", _0.iter().map(|((package, group), specifiers)| { + if let Some(group) = group { + format!("- `{package} --group {group}`: `{specifiers}`") + } else { + format!("- `{package}`: `{specifiers}`") + } + }).join("\n"))] + DisjointRequiresPython(BTreeMap<(PackageName, Option), VersionSpecifiers>), #[error("Environment marker is empty")] EmptyEnvironment, @@ -432,20 +438,17 @@ 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(groups).next().is_none() { + if requires_python.is_empty() { return Ok(None); } - match RequiresPython::intersection( - workspace - .requires_python(groups) - .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(groups) - .map(|(name, specifiers)| (name.clone(), specifiers.clone())) + requires_python + .into_iter() + .map(|(package, group, specifiers)| ((package.clone(), group), specifiers)) .collect(), )), } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 9d6065373642d..29d9d244a907b 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -21071,15 +21071,14 @@ 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: Detected a cycle in `dependency-groups`: `bar` -> `foobar` -> `foo` -> `bar` + "); Ok(()) } @@ -21105,15 +21104,14 @@ 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: 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(()) } @@ -21136,15 +21134,14 @@ 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: Failed to find group `bar` included by `foo` + "); Ok(()) } @@ -21167,31 +21164,29 @@ 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: 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: Failed to parse entry in group `foo`: `invalid!` + Caused by: no such comparison operator "!", must be one of ~= == != <= >= < > === + invalid! + ^ + "#); Ok(()) } @@ -21288,12 +21283,11 @@ 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: Group `foo` contains an unknown dependency object specifier: {"bar": "unknown"} "#); Ok(()) diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 121915ef0b65b..1e55b7d6223c6 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -4598,6 +4598,286 @@ 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"]); + + 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`. However, a workspace member (`project`) supports Python >=3.11. To install the workspace member on its own, navigate to ``, then run `uv venv --python 3.11.[X]` followed by `uv pip install -e .`. + "); + + // Enabling foo we can't find an interpretter + 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 managed installations or search path + "); + + 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", {include-group = "foo"}] + 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: The workspace contains conflicting Python requirements: + - `project`: `>=3.11` + - `project --group bar`: `<3.13, >=3.13` + - `project --group dev`: `>=3.12, <3.13` + "); + + // Explicitly requesting an in-range python can upgrade + 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.*`. However, a workspace member (`project`) supports Python >=3.11. To install the workspace member on its own, navigate to ``, then run `uv venv --python 3.13.[X]` followed by `uv pip install -e .`. + "); + + // 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 5 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 ----- + Resolved 6 packages in [TIME] + Audited 5 packages in [TIME] + "); + + // 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.*`. However, a workspace member (`project`) supports Python >=3.11. To install the workspace member on its own, navigate to ``, then run `uv venv --python 3.13.[X]` followed by `uv pip install -e .`. + "); + Ok(()) +} + /// Test that a signal n makes the process exit with code 128+n. #[cfg(unix)] #[test] diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index c2026d51c0fb8..80e17c86476a5 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -8940,52 +8940,47 @@ 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: 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: 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: 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: 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: 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 1bd6a95363a9d..19c4a69cb4bad 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -509,19 +509,22 @@ fn create_venv_respects_group_requires_python() -> Result<()> { ); // 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" - requires-python = "<3.11" + requires-python = "<3.13" dependencies = [] [dependency-groups] dev = ["sortedcontainers"] + other = ["sniffio"] [tool.uv.dependency-groups] dev = {requires-python = ">=3.10"} + other = {requires-python = ">=3.12"} "# })?; @@ -531,14 +534,15 @@ fn create_venv_respects_group_requires_python() -> Result<()> { ----- stdout ----- ----- stderr ----- - Using CPython 3.10.[X] interpreter at: [PYTHON-3.10] + 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. + // both apply, their intersection is used. However non-default groups + // should not be consulted! let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! { r#" [project] @@ -549,9 +553,11 @@ fn create_venv_respects_group_requires_python() -> Result<()> { [dependency-groups] dev = ["sortedcontainers"] + other = ["sniffio"] [tool.uv.dependency-groups] dev = {requires-python = ">=3.10"} + other = {requires-python = ">=3.12"} "# })?; @@ -568,7 +574,8 @@ fn create_venv_respects_group_requires_python() -> Result<()> { ); // When the top-level requires-python and default group requires-python - // both apply, their intersection is used. + // both apply, their intersection is used. However non-default groups + // should not be consulted! let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! { r#" [project] @@ -579,9 +586,11 @@ fn create_venv_respects_group_requires_python() -> Result<()> { [dependency-groups] dev = ["sortedcontainers"] + other = ["sniffio"] [tool.uv.dependency-groups] dev = {requires-python = ">=3.11"} + other = {requires-python = ">=3.12"} "# })?; @@ -626,6 +635,38 @@ fn create_venv_respects_group_requires_python() -> Result<()> { "### ); + // 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 ----- + × The workspace contains conflicting Python requirements: + │ - `foo`: `<3.12` + │ - `foo --group dev`: `>=3.12` + " + ); + Ok(()) } From c7575468c7c569033abbf0dea1c8fead0e775b5b Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 10 Jun 2025 09:22:58 -0400 Subject: [PATCH 11/23] cleanup group inheritance test --- crates/uv/src/commands/project/add.rs | 2 +- crates/uv/src/commands/project/lock.rs | 2 +- crates/uv/tests/it/run.rs | 44 ++------------------------ 3 files changed, 5 insertions(+), 43 deletions(-) diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 78664e4b268f2..8e3c4a03a6920 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -148,7 +148,7 @@ pub(crate) async fn add( }; // Default extras currently always disabled let defaulted_extras = extras.with_defaults(DefaultExtras::default()); - // Default groups we need the actual project for, interpretter discovery will use this! + // Default groups we need the actual project for, interpreter discovery will use this! let defaulted_groups; let target = if let Some(script) = script { diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 099ab1a850827..b57df429be557 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -143,7 +143,7 @@ pub(crate) async fn lock( LockTarget::Workspace(workspace) => ProjectInterpreter::discover( workspace, project_dir, - // Don't enable any groups' requires-python for interpretter discovery + // Don't enable any groups' requires-python for interpreter discovery &DependencyGroupsWithDefaults::none(), python.as_deref().map(PythonRequest::parse), &network_settings, diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 1e55b7d6223c6..c04689ebcb09b 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -4726,7 +4726,7 @@ fn run_groups_requires_python() -> Result<()> { error: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`. However, a workspace member (`project`) supports Python >=3.11. To install the workspace member on its own, navigate to ``, then run `uv venv --python 3.11.[X]` followed by `uv pip install -e .`. "); - // Enabling foo we can't find an interpretter + // 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" @@ -4756,7 +4756,7 @@ fn run_groups_include_requires_python() -> Result<()> { [dependency-groups] foo = ["anyio"] - bar = ["iniconfig", {include-group = "foo"}] + bar = ["iniconfig"] baz = ["iniconfig"] dev = ["sniffio", {include-group = "foo"}, {include-group = "baz"}] @@ -4821,48 +4821,10 @@ fn run_groups_include_requires_python() -> Result<()> { ----- stderr ----- error: The workspace contains conflicting Python requirements: - `project`: `>=3.11` - - `project --group bar`: `<3.13, >=3.13` + - `project --group bar`: `>=3.13` - `project --group dev`: `>=3.12, <3.13` "); - // Explicitly requesting an in-range python can upgrade - 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.*`. However, a workspace member (`project`) supports Python >=3.11. To install the workspace member on its own, navigate to ``, then run `uv venv --python 3.13.[X]` followed by `uv pip install -e .`. - "); - - // 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 5 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 ----- - Resolved 6 packages in [TIME] - Audited 5 packages in [TIME] - "); - // Explicitly requesting an out-of-range python fails uv_snapshot!(context.filters(), context.run() .arg("-p").arg("3.13") From 096d0b7ef776f8b3a1899e92d883c206182ba98f Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 10 Jun 2025 09:38:02 -0400 Subject: [PATCH 12/23] fix doc --- crates/uv-workspace/src/pyproject.rs | 4 ++-- docs/reference/settings.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 5b1dc165e1319..918c88404b466 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -358,8 +358,8 @@ pub struct ToolUv { default = "[]", value_type = "dict", example = r#" - [tool.uv.dependency-groups.mygroup] - requires-python = ">=3.12" + [tool.uv.dependency-groups] + my-group = {requires-python = ">=3.12"} "# )] pub dependency_groups: Option, diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 6c63af092fbec..820a31ca55682 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -139,8 +139,8 @@ Additional settings for `dependency-groups` ```toml title="pyproject.toml" -[tool.uv.dependency-groups.mygroup] -requires-python = ">=3.12" +[tool.uv.dependency-groups] +my-group = {requires-python = ">=3.12"} ``` --- From 3bacbc909eb008ff6ad9b776aa12f09e64905c23 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 10 Jun 2025 11:01:28 -0400 Subject: [PATCH 13/23] cleanup diagnostic --- .../src/metadata/requires_dist.rs | 1 + crates/uv-workspace/src/dependency_groups.rs | 59 ++++++++++++++----- crates/uv-workspace/src/workspace.rs | 8 ++- crates/uv/tests/it/lock.rs | 18 ++++-- crates/uv/tests/it/run.rs | 33 +++++++---- crates/uv/tests/it/sync.rs | 15 +++-- 6 files changed, 95 insertions(+), 39 deletions(-) diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 436b40710871c..e9f36f174c0bb 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -108,6 +108,7 @@ impl RequiresDist { }; let dependency_groups = FlatDependencyGroups::from_pyproject_toml( + project_workspace.current_project().root(), project_workspace.current_project().pyproject_toml(), )?; diff --git a/crates/uv-workspace/src/dependency_groups.rs b/crates/uv-workspace/src/dependency_groups.rs index a3b38b1e396ed..a4e115280e081 100644 --- a/crates/uv-workspace/src/dependency_groups.rs +++ b/crates/uv-workspace/src/dependency_groups.rs @@ -1,11 +1,12 @@ -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; @@ -25,6 +26,7 @@ pub struct FlatDependencyGroup { impl FlatDependencyGroups { pub fn from_pyproject_toml( + path: &Path, pyproject_toml: &PyProjectToml, ) -> Result { // Otherwise, return the dependency groups in the non-project workspace root. @@ -56,7 +58,15 @@ impl FlatDependencyGroups { &dependency_groups, group_settings.inner(), ) - .map_err(|err| err.with_dev_dependencies(dev_dependencies))?; + .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 `dev-dependencies` is defined. if let Some(dev_dependencies) = dev_dependencies { @@ -75,14 +85,14 @@ impl FlatDependencyGroups { fn from_dependency_groups( groups: &BTreeMap<&GroupName, &Vec>, settings: &BTreeMap, - ) -> Result { + ) -> Result { fn resolve_group<'data>( 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 @@ -90,7 +100,7 @@ impl FlatDependencyGroups { .last() .copied() .expect("parent when group is missing"); - return Err(DependencyGroupError::GroupNotFound( + return Err(DependencyGroupErrorInner::GroupNotFound( name.clone(), parent_name.clone(), )); @@ -98,7 +108,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(), ))); } @@ -117,7 +127,7 @@ impl FlatDependencyGroups { 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), @@ -140,10 +150,12 @@ impl FlatDependencyGroups { } } DependencyGroupSpecifier::Object(map) => { - return Err(DependencyGroupError::DependencyObjectSpecifierNotSupported( - name.clone(), - map.clone(), - )); + return Err( + DependencyGroupErrorInner::DependencyObjectSpecifierNotSupported( + name.clone(), + map.clone(), + ), + ); } } } @@ -223,7 +235,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!("`{package}`") +} else if package.is_empty() { + format!("`{path}`") +} else { + format!("`{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, @@ -242,7 +271,7 @@ pub enum DependencyGroupError { DependencyObjectSpecifierNotSupported(GroupName, BTreeMap), } -impl DependencyGroupError { +impl DependencyGroupErrorInner { /// Enrich a [`DependencyGroupError`] with the `tool.uv.dev-dependencies` metadata, if applicable. #[must_use] pub fn with_dev_dependencies( @@ -250,10 +279,10 @@ 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, } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 25506f8d6eb4a..24077181ebfa6 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -438,7 +438,7 @@ impl Workspace { // 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.pyproject_toml)?; + FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?; let group_requires = dependency_groups .into_iter() @@ -484,8 +484,10 @@ impl Workspace { Ok(BTreeMap::default()) } else { // Otherwise, return the dependency groups in the non-project workspace root. - let dependency_groups = - FlatDependencyGroups::from_pyproject_toml(&self.pyproject_toml)?; + let dependency_groups = FlatDependencyGroups::from_pyproject_toml( + &self.install_path, + &self.pyproject_toml, + )?; Ok(dependency_groups.into_inner()) } } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 29d9d244a907b..f9ccad02db3aa 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -21077,7 +21077,8 @@ fn lock_group_include_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Detected a cycle in `dependency-groups`: `bar` -> `foobar` -> `foo` -> `bar` + error: `project` has malformed dependency groups + Caused by: Detected a cycle in `dependency-groups`: `bar` -> `foobar` -> `foo` -> `bar` "); Ok(()) @@ -21110,7 +21111,8 @@ fn lock_group_include_dev() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: 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` 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(()) @@ -21140,7 +21142,8 @@ fn lock_group_include_missing() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Failed to find group `bar` included by `foo` + error: `project` has malformed dependency groups + Caused by: Failed to find group `bar` included by `foo` "); Ok(()) @@ -21170,7 +21173,8 @@ fn lock_group_invalid_entry_package() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Failed to parse entry in group `foo`: `invalid!` + error: `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! ^ @@ -21182,7 +21186,8 @@ fn lock_group_invalid_entry_package() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Failed to parse entry in group `foo`: `invalid!` + error: `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! ^ @@ -21287,7 +21292,8 @@ fn lock_group_invalid_entry_table() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Group `foo` contains an unknown dependency object specifier: {"bar": "unknown"} + error: `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/run.rs b/crates/uv/tests/it/run.rs index c04689ebcb09b..48c6bfaa79908 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -4727,16 +4727,29 @@ fn run_groups_requires_python() -> Result<()> { "); // 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 managed installations or search path - "); + if cfg!(windows) { + 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 managed installations, search path, or registry + "); + } else { + 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 managed installations or search path + "); + } Ok(()) } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 80e17c86476a5..2343dbaeb30c2 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -8944,7 +8944,8 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: `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" @@ -8953,7 +8954,8 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: `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" @@ -8962,7 +8964,8 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: `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" @@ -8971,7 +8974,8 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: `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" @@ -8980,7 +8984,8 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` + error: `example` has malformed dependency groups + Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); Ok(()) From 46b266f2ffb37d70558af9e452d0fc52cd738065 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 10 Jun 2025 15:30:21 -0400 Subject: [PATCH 14/23] cleanup from review --- .../uv-configuration/src/dependency_groups.rs | 1 + crates/uv-workspace/src/dependency_groups.rs | 48 +++++++++++-------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/crates/uv-configuration/src/dependency_groups.rs b/crates/uv-configuration/src/dependency_groups.rs index b7d010835d9af..a3b90ea5fe0ac 100644 --- a/crates/uv-configuration/src/dependency_groups.rs +++ b/crates/uv-configuration/src/dependency_groups.rs @@ -303,6 +303,7 @@ impl DependencyGroupsWithDefaults { 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-workspace/src/dependency_groups.rs b/crates/uv-workspace/src/dependency_groups.rs index a4e115280e081..6fbd2294c8330 100644 --- a/crates/uv-workspace/src/dependency_groups.rs +++ b/crates/uv-workspace/src/dependency_groups.rs @@ -25,11 +25,13 @@ pub struct FlatDependencyGroup { } 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 { - // Otherwise, return the dependency groups in the non-project workspace root. // First, collect `tool.uv.dev_dependencies` let dev_dependencies = pyproject_toml .tool @@ -68,7 +70,13 @@ impl FlatDependencyGroups { error: err.with_dev_dependencies(dev_dependencies), })?; - // Add the `dev` group, if `dev-dependencies` is defined. + // 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()) @@ -120,7 +128,7 @@ impl FlatDependencyGroups { parents.push(name); let mut requirements = Vec::with_capacity(specifiers.len()); - let mut full_requires_python = None; + let mut requires_python_intersection = VersionSpecifiers::empty(); for specifier in *specifiers { match specifier { DependencyGroupSpecifier::Requirement(requirement) => { @@ -139,14 +147,12 @@ impl FlatDependencyGroups { resolve_group(resolved, groups, settings, include_group, parents)?; if let Some(included) = resolved.get(include_group) { requirements.extend(included.requirements.iter().cloned()); - let versions = full_requires_python + + // Intersect the requires-python for this group with the the included group's + requires_python_intersection = requires_python_intersection .into_iter() - .flatten() .chain(included.requires_python.clone().into_iter().flatten()) - .collect::>(); - let new_requires_python = VersionSpecifiers::from_iter(versions); - full_requires_python = - (!new_requires_python.is_empty()).then_some(new_requires_python); + .collect(); } } DependencyGroupSpecifier::Object(map) => { @@ -164,18 +170,16 @@ impl FlatDependencyGroups { let DependencyGroupSettings { requires_python } = settings.get(name).unwrap_or(&empty_settings); if let Some(requires_python) = requires_python { - // Merge in this requires-python to the full flattened version - let versions = full_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() - .flatten() .chain(requires_python.clone()) - .collect::>(); - let new_requires_python = VersionSpecifiers::from_iter(versions); - full_requires_python = - (!new_requires_python.is_empty()).then_some(new_requires_python); - // Merge in this requires-python to every requirement of this group - // We don't need to merge the full_requires_python because included - // groups already applied theirs. + .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(); @@ -189,7 +193,11 @@ impl FlatDependencyGroups { name.clone(), FlatDependencyGroup { requirements, - requires_python: full_requires_python, + requires_python: if requires_python_intersection.is_empty() { + None + } else { + Some(requires_python_intersection) + }, }, ); Ok(()) From 905ee44d5d56b15e779afe1ce011083bf25ce99f Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Wed, 11 Jun 2025 20:14:27 -0400 Subject: [PATCH 15/23] cleanups from review --- crates/uv-workspace/src/dependency_groups.rs | 6 +-- crates/uv/src/commands/project/lock_target.rs | 2 +- crates/uv/src/commands/project/mod.rs | 6 +-- crates/uv/src/commands/project/version.rs | 1 - crates/uv/tests/it/lock.rs | 22 +++++----- crates/uv/tests/it/run.rs | 44 +++++++------------ crates/uv/tests/it/sync.rs | 10 ++--- crates/uv/tests/it/venv.rs | 21 +++++---- 8 files changed, 49 insertions(+), 63 deletions(-) diff --git a/crates/uv-workspace/src/dependency_groups.rs b/crates/uv-workspace/src/dependency_groups.rs index 6fbd2294c8330..fb95282381dd9 100644 --- a/crates/uv-workspace/src/dependency_groups.rs +++ b/crates/uv-workspace/src/dependency_groups.rs @@ -244,13 +244,13 @@ impl IntoIterator for FlatDependencyGroups { #[derive(Debug, Error)] #[error("{} has malformed dependency groups", if path.is_empty() && package.is_empty() { - "project".to_string() + "Project".to_string() } else if path.is_empty() { - format!("`{package}`") + format!("Project `{package}`") } else if package.is_empty() { format!("`{path}`") } else { - format!("`{package} @ {path}") + format!("Project `{package} @ {path}`") })] pub struct DependencyGroupError { package: String, diff --git a/crates/uv/src/commands/project/lock_target.rs b/crates/uv/src/commands/project/lock_target.rs index 13c980530ca60..4618b3b841e04 100644 --- a/crates/uv/src/commands/project/lock_target.rs +++ b/crates/uv/src/commands/project/lock_target.rs @@ -217,7 +217,7 @@ impl<'lock> LockTarget<'lock> { pub(crate) fn requires_python(self) -> Result, ProjectError> { match self { Self::Workspace(workspace) => { - // TODO(Gankra): I'm not sure if this should be none of the groups or *all* + // When locking, don't try to enforce requires-python bounds that appear on groups let groups = DependencyGroupsWithDefaults::none(); find_requires_python(workspace, &groups) } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 8fa5451d5fa80..e953ece0a6bc5 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -195,11 +195,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(|((package, group), specifiers)| { + #[error("Found conflicting Python requirements:\n{}", _0.iter().map(|((package, group), specifiers)| { if let Some(group) = group { - format!("- `{package} --group {group}`: `{specifiers}`") + format!("- {package}:{group}: {specifiers}") } else { - format!("- `{package}`: `{specifiers}`") + format!("- {package}: {specifiers}") } }).join("\n"))] DisjointRequiresPython(BTreeMap<(PackageName, Option), VersionSpecifiers>), diff --git a/crates/uv/src/commands/project/version.rs b/crates/uv/src/commands/project/version.rs index 6f911a211179d..f767441867a23 100644 --- a/crates/uv/src/commands/project/version.rs +++ b/crates/uv/src/commands/project/version.rs @@ -380,7 +380,6 @@ async fn lock_and_sync( } // Determine the groups and extras that should be enabled. - // TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here? let default_groups = default_dependency_groups(project.pyproject_toml())?; let default_extras = DefaultExtras::default(); let groups = DependencyGroups::default().with_defaults(default_groups); diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index f9ccad02db3aa..db22983b47e26 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(()) } @@ -21077,7 +21077,7 @@ fn lock_group_include_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `project` has malformed dependency groups + error: Project `project` has malformed dependency groups Caused by: Detected a cycle in `dependency-groups`: `bar` -> `foobar` -> `foo` -> `bar` "); @@ -21111,7 +21111,7 @@ fn lock_group_include_dev() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `project` has malformed dependency groups + 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. "#); @@ -21142,7 +21142,7 @@ fn lock_group_include_missing() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `project` has malformed dependency groups + error: Project `project` has malformed dependency groups Caused by: Failed to find group `bar` included by `foo` "); @@ -21173,7 +21173,7 @@ fn lock_group_invalid_entry_package() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `project` has malformed dependency groups + 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! @@ -21186,7 +21186,7 @@ fn lock_group_invalid_entry_package() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `project` has malformed dependency groups + 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! @@ -21292,7 +21292,7 @@ fn lock_group_invalid_entry_table() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `project` has malformed dependency groups + error: Project `project` has malformed dependency groups Caused by: Group `foo` contains an unknown dependency object specifier: {"bar": "unknown"} "#); diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 48c6bfaa79908..647464e969b39 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -4601,7 +4601,8 @@ fn run_default_groups() -> Result<()> { #[test] fn run_groups_requires_python() -> Result<()> { - let context = TestContext::new_with_versions(&["3.11", "3.12", "3.13"]); + 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( @@ -4727,29 +4728,16 @@ fn run_groups_requires_python() -> Result<()> { "); // Enabling foo we can't find an interpreter - if cfg!(windows) { - 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 managed installations, search path, or registry - "); - } else { - 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 managed installations or search path - "); - } + 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(()) } @@ -4832,10 +4820,10 @@ fn run_groups_include_requires_python() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: The workspace contains conflicting Python requirements: - - `project`: `>=3.11` - - `project --group bar`: `>=3.13` - - `project --group dev`: `>=3.12, <3.13` + 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 diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 2343dbaeb30c2..f1c8790e4ea84 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -8944,7 +8944,7 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `example` has malformed dependency groups + error: Project `example` has malformed dependency groups Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); @@ -8954,7 +8954,7 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `example` has malformed dependency groups + error: Project `example` has malformed dependency groups Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); @@ -8964,7 +8964,7 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `example` has malformed dependency groups + error: Project `example` has malformed dependency groups Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); @@ -8974,7 +8974,7 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `example` has malformed dependency groups + error: Project `example` has malformed dependency groups Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); @@ -8984,7 +8984,7 @@ fn transitive_group_conflicts_cycle() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: `example` has malformed dependency groups + error: Project `example` has malformed dependency groups Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev` "); diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 19c4a69cb4bad..bae0af8f3dee9 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -493,19 +493,19 @@ fn create_venv_respects_pyproject_requires_python() -> Result<()> { #[test] fn create_venv_respects_group_requires_python() -> Result<()> { - let context = TestContext::new_with_versions(&["3.11", "3.9", "3.10", "3.12"]); + 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###" + uv_snapshot!(context.filters(), context.venv(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- - Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + 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 @@ -515,7 +515,6 @@ fn create_venv_respects_group_requires_python() -> Result<()> { [project] name = "foo" version = "1.0.0" - requires-python = "<3.13" dependencies = [] [dependency-groups] @@ -534,7 +533,7 @@ fn create_venv_respects_group_requires_python() -> Result<()> { ----- stdout ----- ----- stderr ----- - Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Using CPython 3.10.[X] interpreter at: [PYTHON-3.10] Creating virtual environment at: .venv Activate with: source .venv/[BIN]/activate " @@ -542,7 +541,7 @@ fn create_venv_respects_group_requires_python() -> Result<()> { // 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! + // 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] @@ -575,7 +574,7 @@ fn create_venv_respects_group_requires_python() -> Result<()> { // 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! + // should not be consulted! (here the group wins) let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! { r#" [project] @@ -661,9 +660,9 @@ fn create_venv_respects_group_requires_python() -> Result<()> { ----- stdout ----- ----- stderr ----- - × The workspace contains conflicting Python requirements: - │ - `foo`: `<3.12` - │ - `foo --group dev`: `>=3.12` + × Found conflicting Python requirements: + │ - foo: <3.12 + │ - foo:dev: >=3.12 " ); From c1532fda0bd2a5ec5183ce12da4f9ae54752422c Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Wed, 11 Jun 2025 22:07:55 -0400 Subject: [PATCH 16/23] try to improve error message --- crates/uv-workspace/src/lib.rs | 4 +- crates/uv-workspace/src/workspace.rs | 16 +-- crates/uv/src/commands/project/mod.rs | 161 +++++++++++--------------- crates/uv/src/commands/project/run.rs | 1 + crates/uv/src/commands/python/find.rs | 1 + crates/uv/src/commands/venv.rs | 1 + crates/uv/tests/it/lock.rs | 12 +- crates/uv/tests/it/python_find.rs | 12 +- crates/uv/tests/it/run.rs | 18 +-- crates/uv/tests/it/sync.rs | 32 ++--- crates/uv/tests/it/venv.rs | 12 +- 11 files changed, 123 insertions(+), 147 deletions(-) diff --git a/crates/uv-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs index 83be6bd887946..0e1b3974c3e95 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/workspace.rs b/crates/uv-workspace/src/workspace.rs index 24077181ebfa6..ed99d3d30c293 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -96,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))] @@ -414,13 +416,11 @@ impl Workspace { } /// Returns an iterator over the `requires-python` values for each member of the workspace. - #[allow(clippy::type_complexity)] pub fn requires_python( &self, groups: &DependencyGroupsWithDefaults, - ) -> Result, VersionSpecifiers)>, DependencyGroupError> - { - let mut requires = Vec::new(); + ) -> 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 // @@ -432,7 +432,7 @@ impl Workspace { .project .as_ref() .and_then(|project| project.requires_python.as_ref()) - .map(|requires_python| (name, None, requires_python.clone())); + .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 @@ -444,9 +444,9 @@ impl Workspace { .into_iter() .filter_map(move |(group_name, flat_group)| { if groups.contains(&group_name) { - flat_group - .requires_python - .map(|requires_python| (name, Some(group_name), requires_python)) + flat_group.requires_python.map(|requires_python| { + ((name.to_owned(), Some(group_name)), requires_python) + }) } else { None } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index e953ece0a6bc5..ec3c29119a7fa 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -46,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}; @@ -109,19 +109,22 @@ 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) )] - RequestedPythonProjectIncompatibility(Version, RequiresPython), + RequestedPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources), #[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}`. Use `uv python pin` to update the `.python-version` file to a compatible version{}", + format_optional_requires_python_sources(_3) )] - DotPythonVersionProjectIncompatibility(String, Version, RequiresPython), + DotPythonVersionProjectIncompatibility(String, Version, RequiresPython, RequiresPythonSources), #[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) )] - RequiresPythonProjectIncompatibility(Version, RequiresPython), + RequiresPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources), #[error( "The requested interpreter resolved to Python {0}, which is incompatible with the script's Python requirement: `{1}`" @@ -138,34 +141,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), @@ -195,13 +170,10 @@ pub(crate) enum ProjectError { #[error("Environment markers `{0}` don't overlap with Python requirement `{1}`")] DisjointEnvironment(MarkerTreeContents, VersionSpecifiers), - #[error("Found conflicting Python requirements:\n{}", _0.iter().map(|((package, group), specifiers)| { - if let Some(group) = group { - format!("- {package}:{group}: {specifiers}") - } else { - format!("- {package}: {specifiers}") - } - }).join("\n"))] + #[error( + "Found conflicting Python requirements:\n{}", + format_requires_python_sources(_0) + )] DisjointRequiresPython(BTreeMap<(PackageName, Option), VersionSpecifiers>), #[error("Environment marker is empty")] @@ -445,12 +417,7 @@ pub(crate) fn find_requires_python( } match RequiresPython::intersection(requires_python.iter().map(|(.., specifiers)| specifiers)) { Some(requires_python) => Ok(Some(requires_python)), - None => Err(ProjectError::DisjointRequiresPython( - requires_python - .into_iter() - .map(|(package, group, specifiers)| ((package.clone(), group), specifiers)) - .collect(), - )), + None => Err(ProjectError::DisjointRequiresPython(requires_python)), } } @@ -462,6 +429,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> { @@ -469,57 +437,20 @@ 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::(); match source { PythonRequestSource::UserRequest => { Err(ProjectError::RequestedPythonProjectIncompatibility( interpreter.python_version().clone(), requires_python.clone(), + conflicting_requires, )) } PythonRequestSource::DotPythonVersion(file) => { @@ -527,12 +458,14 @@ pub(crate) fn validate_project_requires_python( file.path().user_display().to_string(), interpreter.python_version().clone(), requires_python.clone(), + conflicting_requires, )) } PythonRequestSource::RequiresPython => { Err(ProjectError::RequiresPythonProjectIncompatibility( interpreter.python_version().clone(), requires_python.clone(), + conflicting_requires, )) } } @@ -743,7 +676,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) @@ -1014,6 +953,7 @@ impl ProjectInterpreter { validate_project_requires_python( &interpreter, Some(workspace), + groups, requires_python, &source, )?; @@ -2697,6 +2637,39 @@ 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) -> String { + // If there's lots of conflicts, print a list + if conflicts.len() > 1 { + return format!( + ". The following conflicts were found:\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 { + return format!(". The requirement comes from `{package}:{group}`."); + } + return format!(". The requirement comes from `{package}`."); + } + // Otherwise don't elaborate + String::new() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 97b5001bc7ae7..f97ffdbc19351 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -632,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, )?; diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 4caa7bceb69d9..1e5693c655964 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -84,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/venv.rs b/crates/uv/src/commands/venv.rs index ca0dd630545fc..a50c0e1553b16 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -257,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/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index db22983b47e26..86cfcc26a9f30 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -18298,29 +18298,29 @@ 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`. The requirement comes from `project`. + "); // 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`. Use `uv python pin` to update the `.python-version` file to a compatible version. The requirement comes from `project`. + "); Ok(()) } diff --git a/crates/uv/tests/it/python_find.rs b/crates/uv/tests/it/python_find.rs index 11bf421a3715a..a6c87bcf6cd14 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`. The requirement comes from `project`. + "); // Or `--no-project` is used uv_snapshot!(context.filters(), context.python_find().arg("--no-project"), @r###" @@ -367,15 +367,15 @@ 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`. Use `uv python pin` to update the `.python-version` file to a compatible version. The requirement comes from `project`. + "); // 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 647464e969b39..eb2b7cf57c8ed 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`. The requirement comes from `foo`. "); Ok(()) @@ -3136,25 +3136,25 @@ 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`. Use `uv python pin` to update the `.python-version` file to a compatible version. The requirement comes from `foo`. + "); // ...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`. Use `uv python pin` to update the `.python-version` file to a compatible version. The requirement comes from `foo`. + "); Ok(()) } @@ -4724,7 +4724,7 @@ fn run_groups_requires_python() -> Result<()> { ----- 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`. However, a workspace member (`project`) supports Python >=3.11. To install the workspace member on its own, navigate to ``, then run `uv venv --python 3.11.[X]` followed by `uv pip install -e .`. + error: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`. The requirement comes from `project:dev`. "); // Enabling foo we can't find an interpreter @@ -4836,7 +4836,7 @@ fn run_groups_include_requires_python() -> Result<()> { ----- 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.*`. However, a workspace member (`project`) supports Python >=3.11. To install the workspace member on its own, navigate to ``, then run `uv venv --python 3.13.[X]` followed by `uv pip install -e .`. + error: The requested interpreter resolved to Python 3.13.[X], which is incompatible with the project's Python requirement: `==3.12.*`. The requirement comes from `project:dev`. "); Ok(()) } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index f1c8790e4ea84..49c2656d7d366 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -346,7 +346,7 @@ 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`. The requirement comes from `albatross`. "); Ok(()) @@ -4080,17 +4080,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`. The requirement comes from `project`. 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 +5817,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`. The requirement comes from `project`. 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 +5884,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`. The requirement comes from `project`. 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 +6004,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`. The requirement comes from `project`. + "); // But a pin should take precedence uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" @@ -6051,15 +6051,15 @@ 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`. Use `uv python pin` to update the `.python-version` file to a compatible version. The requirement comes from `project`. + "); // 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"); diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index bae0af8f3dee9..0eb21157c0793 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -475,17 +475,17 @@ 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`. The requirement comes from `foo`. Creating virtual environment at: .venv Activate with: source .venv/[BIN]/activate - "### + " ); Ok(()) @@ -621,17 +621,17 @@ fn create_venv_respects_group_requires_python() -> Result<()> { "# })?; - 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`. The requirement comes from `foo:dev`. Creating virtual environment at: .venv Activate with: source .venv/[BIN]/activate - "### + " ); // We error if there's no compatible version From 1c39f16290cf6e2f9a2a83905c3c2758017b3aeb Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Thu, 12 Jun 2025 09:17:44 -0400 Subject: [PATCH 17/23] wip --- crates/uv-workspace/Cargo.toml | 2 +- crates/uv-workspace/src/pyproject.rs | 2 +- crates/uv/src/commands/project/mod.rs | 8 ++++---- docs/reference/settings.md | 2 +- uv.schema.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index 941009e823eff..36059f10f9a22 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 } @@ -44,7 +45,6 @@ tokio = { workspace = true } toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true } -uv-configuration.workspace = true [dev-dependencies] anyhow = { workspace = true } diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 918c88404b466..803e017dbc17a 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -353,7 +353,7 @@ pub struct ToolUv { )] pub default_groups: Option, - /// Additional settings for `dependency-groups` + /// Additional settings for `dependency-groups`. #[option( default = "[]", value_type = "dict", diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index ec3c29119a7fa..e55573d9327e2 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -115,7 +115,7 @@ pub(crate) enum ProjectError { RequestedPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources), #[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) )] DotPythonVersionProjectIncompatibility(String, Version, RequiresPython, RequiresPythonSources), @@ -2654,7 +2654,7 @@ fn format_optional_requires_python_sources(conflicts: &RequiresPythonSources) -> // If there's lots of conflicts, print a list if conflicts.len() > 1 { return format!( - ". The following conflicts were found:\n{}", + ".\nThe following conflicts were found:\n{}", format_requires_python_sources(conflicts) ); } @@ -2662,9 +2662,9 @@ fn format_optional_requires_python_sources(conflicts: &RequiresPythonSources) -> if conflicts.len() == 1 { let ((package, group), _) = conflicts.iter().next().unwrap(); if let Some(group) = group { - return format!(". The requirement comes from `{package}:{group}`."); + return format!(" (from `{package}:{group}`)."); } - return format!(". The requirement comes from `{package}`."); + return format!(" (from `{package}`)."); } // Otherwise don't elaborate String::new() diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 820a31ca55682..8076aa9c3bebf 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -129,7 +129,7 @@ default-groups = ["docs"] ### [`dependency-groups`](#dependency-groups) {: #dependency-groups } -Additional settings for `dependency-groups` +Additional settings for `dependency-groups`. **Default value**: `[]` diff --git a/uv.schema.json b/uv.schema.json index a32db83dbf322..68440a0f5ef24 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -152,7 +152,7 @@ ] }, "dependency-groups": { - "description": "Additional settings for `dependency-groups`", + "description": "Additional settings for `dependency-groups`.", "anyOf": [ { "$ref": "#/definitions/ToolUvDependencyGroups" From 9bd5933847564f41ccf372fac83e6ed8000496af Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 13 Jun 2025 15:10:37 -0400 Subject: [PATCH 18/23] review comments --- crates/uv-workspace/src/pyproject.rs | 7 + crates/uv/tests/it/sync.rs | 245 +++++++++++++++++++++++++++ docs/reference/settings.md | 7 + uv.schema.json | 2 +- 4 files changed, 260 insertions(+), 1 deletion(-) diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 803e017dbc17a..6499aad5d706f 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -354,6 +354,13 @@ 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", diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 49c2656d7d366..919bca4a13937 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -352,6 +352,251 @@ fn mixed_requires_python() -> Result<()> { Ok(()) } +/// Ensure that group requires-python solves an actual problem +#[test] +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 succeed, locking for Python 3.8. + // ...but it doesn't! HRM! Is this a bug in sync? + 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 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"} + "#, + )?; + + 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 27 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] +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 succeed, locking for Python 3.8. + // ...but it doesn't! HRM! Is this a bug in sync? + 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 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"} + "#, + )?; + + 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 27 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(()) +} + #[test] fn check() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 8076aa9c3bebf..593cea8bec2f4 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -131,6 +131,13 @@ default-groups = ["docs"] 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` diff --git a/uv.schema.json b/uv.schema.json index 68440a0f5ef24..e44c1825bc019 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -152,7 +152,7 @@ ] }, "dependency-groups": { - "description": "Additional settings for `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" From 7fadccfab1dcaabe50aa323f4cfb75254f141cf7 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 13 Jun 2025 15:20:11 -0400 Subject: [PATCH 19/23] fixup tests --- crates/uv/tests/it/sync.rs | 58 ++++++++++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 919bca4a13937..bbc937f4c21e4 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -378,8 +378,10 @@ fn group_requires_python_useful_defaults() -> Result<()> { let init = src.child("__init__.py"); init.touch()?; - // Running `uv sync --no-dev` should succeed, locking for Python 3.8. - // ...but it doesn't! HRM! Is this a bug in sync? + // 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 succesfully install, + // even though it's not enabled! uv_snapshot!(context.filters(), context.sync() .arg("--no-dev"), @r" success: false @@ -397,7 +399,7 @@ fn group_requires_python_useful_defaults() -> Result<()> { 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 fail, as now sphinx is involved + // Running `uv sync` should always fail, as now sphinx is involved uv_snapshot!(context.filters(), context.sync(), @r" success: false exit_code: 1 @@ -430,6 +432,25 @@ fn group_requires_python_useful_defaults() -> Result<()> { "#, )?; + // 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 @@ -440,7 +461,7 @@ fn group_requires_python_useful_defaults() -> Result<()> { Removed virtual environment at: .venv Creating virtual environment at: .venv Resolved 29 packages in [TIME] - Prepared 27 packages in [TIME] + Prepared 22 packages in [TIME] Installed 27 packages in [TIME] + alabaster==0.7.16 + anyio==4.3.0 @@ -500,8 +521,10 @@ fn group_requires_python_useful_non_defaults() -> Result<()> { let init = src.child("__init__.py"); init.touch()?; - // Running `uv sync` should succeed, locking for Python 3.8. - // ...but it doesn't! HRM! Is this a bug in sync? + // 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 succesfully install, + // even though it's not enabled, or even a default! uv_snapshot!(context.filters(), context.sync(), @r" success: false exit_code: 1 @@ -518,7 +541,7 @@ fn group_requires_python_useful_non_defaults() -> Result<()> { 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 fail, as now sphinx is involved + // 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 @@ -552,6 +575,25 @@ fn group_requires_python_useful_non_defaults() -> Result<()> { "#, )?; + // 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 @@ -563,7 +605,7 @@ fn group_requires_python_useful_non_defaults() -> Result<()> { Removed virtual environment at: .venv Creating virtual environment at: .venv Resolved 29 packages in [TIME] - Prepared 27 packages in [TIME] + Prepared 22 packages in [TIME] Installed 27 packages in [TIME] + alabaster==0.7.16 + anyio==4.3.0 From 51060712f8539d28a37c1dbe893bcffcc80ec2ea Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 13 Jun 2025 15:22:54 -0400 Subject: [PATCH 20/23] typo --- crates/uv/tests/it/sync.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index bbc937f4c21e4..64780e2b7c71b 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -380,7 +380,7 @@ fn group_requires_python_useful_defaults() -> Result<()> { // 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 succesfully install, + // 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" @@ -523,7 +523,7 @@ fn group_requires_python_useful_non_defaults() -> Result<()> { // 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 succesfully install, + // 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 From 2948c42b61b9d754d82f55e396d2eac747551738 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 13 Jun 2025 16:54:23 -0400 Subject: [PATCH 21/23] validate group settings --- crates/uv-workspace/src/dependency_groups.rs | 20 ++++++ crates/uv/tests/it/lock.rs | 68 ++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/crates/uv-workspace/src/dependency_groups.rs b/crates/uv-workspace/src/dependency_groups.rs index fb95282381dd9..8503ae3adac56 100644 --- a/crates/uv-workspace/src/dependency_groups.rs +++ b/crates/uv-workspace/src/dependency_groups.rs @@ -203,6 +203,15 @@ impl FlatDependencyGroups { 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(); @@ -277,6 +286,12 @@ pub enum DependencyGroupErrorInner { 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 DependencyGroupErrorInner { @@ -292,6 +307,11 @@ impl DependencyGroupErrorInner { { Self::DevGroupInclude(parent) } + Self::SettingsGroupNotFound(group) + if dev_dependencies.is_some() && group == *DEV_DEPENDENCIES => + { + Self::SettingsDevGroupInclude + } _ => self, } } diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 86cfcc26a9f30..7fe7b45d84afe 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -20928,6 +20928,74 @@ fn lock_group_includes_requires_python() -> Result<()> { 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"); From fdf36bf580ccda989e4a3447d67d8b4d1cd67ebe Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 13 Jun 2025 17:26:09 -0400 Subject: [PATCH 22/23] finale --- crates/uv/src/commands/project/mod.rs | 43 ++++++++++++++++++++------- crates/uv/tests/it/lock.rs | 5 ++-- crates/uv/tests/it/python_find.rs | 5 ++-- crates/uv/tests/it/run.rs | 12 ++++---- crates/uv/tests/it/sync.rs | 13 ++++---- crates/uv/tests/it/venv.rs | 4 +-- 6 files changed, 55 insertions(+), 27 deletions(-) diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index e55573d9327e2..85defd4dd6dc5 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -110,21 +110,27 @@ pub(crate) enum ProjectError { #[error( "The requested interpreter resolved to Python {_0}, which is incompatible with the project's Python requirement: `{_1}`{}", - format_optional_requires_python_sources(_2) + format_optional_requires_python_sources(_2, *_3) )] - RequestedPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources), + 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}`{}\nUse `uv python pin` to update the `.python-version` file to a compatible version", - format_optional_requires_python_sources(_3) + format_optional_requires_python_sources(_3, *_4) )] - DotPythonVersionProjectIncompatibility(String, Version, RequiresPython, RequiresPythonSources), + DotPythonVersionProjectIncompatibility( + String, + Version, + RequiresPython, + RequiresPythonSources, + bool, + ), #[error( "The resolved Python interpreter (Python {_0}) is incompatible with the project's Python requirement: `{_1}`{}", - format_optional_requires_python_sources(_2) + format_optional_requires_python_sources(_2, *_3) )] - RequiresPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources), + RequiresPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources, bool), #[error( "The requested interpreter resolved to Python {0}, which is incompatible with the script's Python requirement: `{1}`" @@ -444,6 +450,9 @@ pub(crate) fn validate_project_requires_python( .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 => { @@ -451,6 +460,7 @@ pub(crate) fn validate_project_requires_python( interpreter.python_version().clone(), requires_python.clone(), conflicting_requires, + workspace_non_trivial, )) } PythonRequestSource::DotPythonVersion(file) => { @@ -459,6 +469,7 @@ pub(crate) fn validate_project_requires_python( interpreter.python_version().clone(), requires_python.clone(), conflicting_requires, + workspace_non_trivial, )) } PythonRequestSource::RequiresPython => { @@ -466,6 +477,7 @@ pub(crate) fn validate_project_requires_python( interpreter.python_version().clone(), requires_python.clone(), conflicting_requires, + workspace_non_trivial, )) } } @@ -2650,11 +2662,14 @@ fn format_requires_python_sources(conflicts: &RequiresPythonSources) -> String { .join("\n") } -fn format_optional_requires_python_sources(conflicts: &RequiresPythonSources) -> String { +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 conflicts were found:\n{}", + ".\nThe following `requires-python` declarations do not permit this version:\n{}", format_requires_python_sources(conflicts) ); } @@ -2662,9 +2677,17 @@ fn format_optional_requires_python_sources(conflicts: &RequiresPythonSources) -> if conflicts.len() == 1 { let ((package, group), _) = conflicts.iter().next().unwrap(); if let Some(group) = group { - return format!(" (from `{package}:{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 format!(" (from `{package}`)."); + return " (from `project.requires-python`)".to_owned(); } // Otherwise don't elaborate String::new() diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 7fe7b45d84afe..260211269a2f9 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -18305,7 +18305,7 @@ fn lock_request_requires_python() -> Result<()> { ----- 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`. The requirement comes from `project`. + 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. @@ -18319,7 +18319,8 @@ fn lock_request_requires_python() -> Result<()> { ----- 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. The requirement comes from `project`. + 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(()) diff --git a/crates/uv/tests/it/python_find.rs b/crates/uv/tests/it/python_find.rs index a6c87bcf6cd14..f438e9b4d9240 100644 --- a/crates/uv/tests/it/python_find.rs +++ b/crates/uv/tests/it/python_find.rs @@ -325,7 +325,7 @@ fn python_find_project() { [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`. The requirement comes from `project`. + 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 @@ -374,7 +374,8 @@ fn python_find_project() { [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. The requirement comes from `project`. + 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 diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index eb2b7cf57c8ed..65d13c5272c67 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`. The requirement comes from `foo`. + 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(()) @@ -3143,7 +3143,8 @@ fn run_isolated_incompatible_python() -> Result<()> { ----- 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. The requirement comes from `foo`. + 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. @@ -3153,7 +3154,8 @@ fn run_isolated_incompatible_python() -> Result<()> { ----- 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. The requirement comes from `foo`. + 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(()) @@ -4724,7 +4726,7 @@ fn run_groups_requires_python() -> Result<()> { ----- 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`. The requirement comes from `project:dev`. + 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 @@ -4836,7 +4838,7 @@ fn run_groups_include_requires_python() -> Result<()> { ----- 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.*`. The requirement comes from `project:dev`. + 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(()) } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 64780e2b7c71b..75892d534c768 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -346,7 +346,7 @@ 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`. The requirement comes from `albatross`. + 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(()) @@ -4374,7 +4374,7 @@ fn sync_custom_environment_path() -> Result<()> { ----- 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`. The requirement comes from `project`. + 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 "); @@ -6111,7 +6111,7 @@ fn sync_invalid_environment() -> Result<()> { ----- 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`. The requirement comes from `project`. + 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 "); @@ -6178,7 +6178,7 @@ fn sync_invalid_environment() -> Result<()> { ----- 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`. The requirement comes from `project`. + 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 "); @@ -6298,7 +6298,7 @@ fn sync_python_version() -> Result<()> { ----- 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`. The requirement comes from `project`. + 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 @@ -6345,7 +6345,8 @@ fn sync_python_version() -> Result<()> { ----- 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. The requirement comes from `project`. + 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 diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 0eb21157c0793..bc35f949014cd 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -482,7 +482,7 @@ fn create_venv_respects_pyproject_requires_python() -> Result<()> { ----- 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`. The requirement comes from `foo`. + 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 " @@ -628,7 +628,7 @@ fn create_venv_respects_group_requires_python() -> Result<()> { ----- 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`. The requirement comes from `foo:dev`. + 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 " From 403adea042f680b7f85d675338939062b2831fba Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Fri, 13 Jun 2025 17:55:06 -0400 Subject: [PATCH 23/23] disable sphinx test on windows --- crates/uv/tests/it/sync.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index 75892d534c768..b19bd09baea8e 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -354,6 +354,7 @@ fn mixed_requires_python() -> Result<()> { /// 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"]); @@ -497,6 +498,7 @@ fn group_requires_python_useful_defaults() -> Result<()> { /// 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"]);