Add [tool.uv.dependency-groups].mygroup.requires-python (#13735)

This allows you to specify requires-python on individual dependency-groups,
with the intended usecase being "oh my dev-dependencies have a higher
requires-python than my actual project".

This includes a large driveby move of the RequiresPython type to
uv-distribution-types to allow us to generate the appropriate markers at
this point in the code. It also migrates RequiresPython from
pubgrub::Range to version_ranges::Ranges, and makes several pub(crate)
items pub, as it's no longer defined in uv_resolver.

Fixes #11606
This commit is contained in:
Aria Desires 2025-06-13 18:04:13 -04:00 committed by GitHub
parent 26db29caac
commit 5021840919
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2072 additions and 538 deletions

View file

@ -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;

View file

@ -295,6 +295,15 @@ pub struct DependencyGroupsWithDefaults {
}
impl DependencyGroupsWithDefaults {
/// Do not enable any groups
///
/// Many places in the code need to know what dependency-groups are active,
/// but various commands or subsystems never enable any dependency-groups,
/// in which case they want this.
pub fn none() -> Self {
DependencyGroups::default().with_defaults(DefaultGroups::default())
}
/// Returns `true` if the specification was enabled, and *only* because it was a default
pub fn contains_because_default(&self, group: &GroupName) -> bool {
self.cur.contains(group) && !self.prev.contains(group)

View file

@ -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)

View file

@ -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;

View file

@ -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<Range<Version>>, requires_python| {
.fold(None, |range: Option<Ranges<Version>>, 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<Version>) -> 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<Version>) -> Self {
pub fn from_range(range: &Ranges<Version>) -> 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<RequiresPythonRange> for Range<Version> {
impl From<RequiresPythonRange> for Ranges<Version> {
fn from(value: RequiresPythonRange) -> Self {
Range::from_range_bounds::<(Bound<Version>, Bound<Version>), _>((
Ranges::from_range_bounds::<(Bound<Version>, Bound<Version>), _>((
value.0.into(),
value.1.into(),
))
@ -592,21 +592,18 @@ impl From<RequiresPythonRange> for Range<Version> {
/// 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<String> {
pub fn try_to_string(self) -> Option<String> {
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
}
}

View file

@ -6,7 +6,7 @@ use rustc_hash::FxHashSet;
use uv_configuration::SourceStrategy;
use uv_distribution_types::{IndexLocations, Requirement};
use uv_normalize::{DEV_DEPENDENCIES, ExtraName, GroupName, PackageName};
use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep508::MarkerTree;
use uv_workspace::dependency_groups::FlatDependencyGroups;
use uv_workspace::pyproject::{Sources, ToolUvSources};
@ -107,41 +107,10 @@ impl RequiresDist {
SourceStrategy::Disabled => &empty,
};
// Collect the dependency groups.
let dependency_groups = {
// First, collect `tool.uv.dev_dependencies`
let dev_dependencies = project_workspace
.current_project()
.pyproject_toml()
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.dev_dependencies.as_ref());
// Then, collect `dependency-groups`
let dependency_groups = project_workspace
.current_project()
.pyproject_toml()
.dependency_groups
.iter()
.flatten()
.collect::<BTreeMap<_, _>>();
// Flatten the dependency groups.
let mut dependency_groups =
FlatDependencyGroups::from_dependency_groups(&dependency_groups)
.map_err(|err| err.with_dev_dependencies(dev_dependencies))?;
// Add the `dev` group, if `dev-dependencies` is defined.
if let Some(dev_dependencies) = dev_dependencies {
dependency_groups
.entry(DEV_DEPENDENCIES.clone())
.or_insert_with(Vec::new)
.extend(dev_dependencies.clone());
}
dependency_groups
};
let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
project_workspace.current_project().root(),
project_workspace.current_project().pyproject_toml(),
)?;
// Now that we've resolved the dependency groups, we can validate that each source references
// a valid extra or group, if present.
@ -150,9 +119,10 @@ impl RequiresDist {
// Lower the dependency groups.
let dependency_groups = dependency_groups
.into_iter()
.map(|(name, requirements)| {
.map(|(name, flat_group)| {
let requirements = match source_strategy {
SourceStrategy::Enabled => requirements
SourceStrategy::Enabled => flat_group
.requirements
.into_iter()
.flat_map(|requirement| {
let requirement_name = requirement.name.clone();
@ -182,9 +152,11 @@ impl RequiresDist {
)
})
.collect::<Result<Box<_>, _>>(),
SourceStrategy::Disabled => {
Ok(requirements.into_iter().map(Requirement::from).collect())
}
SourceStrategy::Disabled => Ok(flat_group
.requirements
.into_iter()
.map(Requirement::from)
.collect()),
}?;
Ok::<(GroupName, Box<_>), MetadataError>((name, requirements))
})
@ -265,7 +237,7 @@ impl RequiresDist {
if let Some(group) = source.group() {
// If the group doesn't exist at all, error.
let Some(dependencies) = dependency_groups.get(group) else {
let Some(flat_group) = dependency_groups.get(group) else {
return Err(MetadataError::MissingSourceGroup(
name.clone(),
group.clone(),
@ -273,7 +245,8 @@ impl RequiresDist {
};
// If there is no such requirement with the group, error.
if !dependencies
if !flat_group
.requirements
.iter()
.any(|requirement| requirement.name == *name)
{

View file

@ -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;

View file

@ -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 {

View file

@ -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;

View file

@ -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<RequiresPythonRange> {

View file

@ -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> {

View file

@ -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,

View file

@ -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.

View file

@ -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.

View file

@ -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::*;

View file

@ -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<VersionsResponse, uv_client::Error>;
pub type WheelMetadataResult = Result<MetadataResponse, uv_distribution::Error>;

View file

@ -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)]

View file

@ -140,6 +140,9 @@ pub struct Options {
#[cfg_attr(feature = "schemars", schemars(skip))]
pub default_groups: Option<serde::de::IgnoredAny>,
#[cfg_attr(feature = "schemars", schemars(skip))]
pub dependency_groups: Option<serde::de::IgnoredAny>,
#[cfg_attr(feature = "schemars", schemars(skip))]
pub managed: Option<serde::de::IgnoredAny>,
@ -1870,6 +1873,7 @@ pub struct OptionsWire {
managed: Option<serde::de::IgnoredAny>,
r#package: Option<serde::de::IgnoredAny>,
default_groups: Option<serde::de::IgnoredAny>,
dependency_groups: Option<serde::de::IgnoredAny>,
dev_dependencies: Option<serde::de::IgnoredAny>,
// Build backend
@ -1934,6 +1938,7 @@ impl From<OptionsWire> for Options {
workspace,
sources,
default_groups,
dependency_groups,
dev_dependencies,
managed,
package,
@ -2010,6 +2015,7 @@ impl From<OptionsWire> for Options {
sources,
dev_dependencies,
default_groups,
dependency_groups,
managed,
package,
}

View file

@ -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 }

View file

@ -1,32 +1,106 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::str::FromStr;
use std::{collections::BTreeMap, path::Path};
use thiserror::Error;
use tracing::error;
use uv_distribution_types::RequiresPython;
use uv_fs::Simplified;
use uv_normalize::{DEV_DEPENDENCIES, GroupName};
use uv_pep440::VersionSpecifiers;
use uv_pep508::Pep508Error;
use uv_pypi_types::{DependencyGroupSpecifier, VerbatimParsedUrl};
use crate::pyproject::{DependencyGroupSettings, PyProjectToml, ToolUvDependencyGroups};
/// PEP 735 dependency groups, with any `include-group` entries resolved.
#[derive(Debug, Default, Clone)]
pub struct FlatDependencyGroups(
BTreeMap<GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
);
pub struct FlatDependencyGroups(BTreeMap<GroupName, FlatDependencyGroup>);
#[derive(Debug, Default, Clone)]
pub struct FlatDependencyGroup {
pub requirements: Vec<uv_pep508::Requirement<VerbatimParsedUrl>>,
pub requires_python: Option<VersionSpecifiers>,
}
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<Self, DependencyGroupError> {
// 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::<BTreeMap<_, _>>();
// Get additional settings
let empty_settings = ToolUvDependencyGroups::default();
let group_settings = pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.dependency_groups.as_ref())
.unwrap_or(&empty_settings);
// Flatten the dependency groups.
let mut dependency_groups = FlatDependencyGroups::from_dependency_groups(
&dependency_groups,
group_settings.inner(),
)
.map_err(|err| DependencyGroupError {
package: pyproject_toml
.project
.as_ref()
.map(|project| project.name.to_string())
.unwrap_or_default(),
path: path.user_display().to_string(),
error: err.with_dev_dependencies(dev_dependencies),
})?;
// Add the `dev` group, if the legacy `dev-dependencies` is defined.
//
// NOTE: the fact that we do this out here means that nothing can inherit from
// the legacy dev-dependencies group (or define a group requires-python for it).
// This is intentional, we want groups to be defined in a standard interoperable
// way, and letting things include-group a group that isn't defined would be a
// mess for other python tools.
if let Some(dev_dependencies) = dev_dependencies {
dependency_groups
.entry(DEV_DEPENDENCIES.clone())
.or_insert_with(FlatDependencyGroup::default)
.requirements
.extend(dev_dependencies.clone());
}
Ok(dependency_groups)
}
/// Resolve the dependency groups (which may contain references to other groups) into concrete
/// lists of requirements.
pub fn from_dependency_groups(
fn from_dependency_groups(
groups: &BTreeMap<&GroupName, &Vec<DependencyGroupSpecifier>>,
) -> Result<Self, DependencyGroupError> {
settings: &BTreeMap<GroupName, DependencyGroupSettings>,
) -> Result<Self, DependencyGroupErrorInner> {
fn resolve_group<'data>(
resolved: &mut BTreeMap<GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
resolved: &mut BTreeMap<GroupName, FlatDependencyGroup>,
groups: &'data BTreeMap<&GroupName, &Vec<DependencyGroupSpecifier>>,
settings: &BTreeMap<GroupName, DependencyGroupSettings>,
name: &'data GroupName,
parents: &mut Vec<&'data GroupName>,
) -> Result<(), DependencyGroupError> {
) -> Result<(), DependencyGroupErrorInner> {
let Some(specifiers) = groups.get(name) else {
// Missing group
let parent_name = parents
@ -34,7 +108,7 @@ impl FlatDependencyGroups {
.last()
.copied()
.expect("parent when group is missing");
return Err(DependencyGroupError::GroupNotFound(
return Err(DependencyGroupErrorInner::GroupNotFound(
name.clone(),
parent_name.clone(),
));
@ -42,7 +116,7 @@ impl FlatDependencyGroups {
// "Dependency Group Includes MUST NOT include cycles, and tools SHOULD report an error if they detect a cycle."
if parents.contains(&name) {
return Err(DependencyGroupError::DependencyGroupCycle(Cycle(
return Err(DependencyGroupErrorInner::DependencyGroupCycle(Cycle(
parents.iter().copied().cloned().collect(),
)));
}
@ -54,13 +128,14 @@ impl FlatDependencyGroups {
parents.push(name);
let mut requirements = Vec::with_capacity(specifiers.len());
let mut requires_python_intersection = VersionSpecifiers::empty();
for specifier in *specifiers {
match specifier {
DependencyGroupSpecifier::Requirement(requirement) => {
match uv_pep508::Requirement::<VerbatimParsedUrl>::from_str(requirement) {
Ok(requirement) => requirements.push(requirement),
Err(err) => {
return Err(DependencyGroupError::GroupParseError(
return Err(DependencyGroupErrorInner::GroupParseError(
name.clone(),
requirement.clone(),
Box::new(err),
@ -69,72 +144,107 @@ impl FlatDependencyGroups {
}
}
DependencyGroupSpecifier::IncludeGroup { include_group } => {
resolve_group(resolved, groups, include_group, parents)?;
requirements
.extend(resolved.get(include_group).into_iter().flatten().cloned());
resolve_group(resolved, groups, settings, include_group, parents)?;
if let Some(included) = resolved.get(include_group) {
requirements.extend(included.requirements.iter().cloned());
// Intersect the requires-python for this group with the the included group's
requires_python_intersection = requires_python_intersection
.into_iter()
.chain(included.requires_python.clone().into_iter().flatten())
.collect();
}
}
DependencyGroupSpecifier::Object(map) => {
return Err(DependencyGroupError::DependencyObjectSpecifierNotSupported(
name.clone(),
map.clone(),
));
return Err(
DependencyGroupErrorInner::DependencyObjectSpecifierNotSupported(
name.clone(),
map.clone(),
),
);
}
}
}
let empty_settings = DependencyGroupSettings::default();
let DependencyGroupSettings { requires_python } =
settings.get(name).unwrap_or(&empty_settings);
if let Some(requires_python) = requires_python {
// Intersect the requires-python for this group to get the final requires-python
// that will be used by interpreter discovery and checking.
requires_python_intersection = requires_python_intersection
.into_iter()
.chain(requires_python.clone())
.collect();
// Add the group requires-python as a marker to each requirement
// We don't use `requires_python_intersection` because each `include-group`
// should already have its markers applied to these.
for requirement in &mut requirements {
let extra_markers =
RequiresPython::from_specifiers(requires_python).to_marker_tree();
requirement.marker.and(extra_markers);
}
}
parents.pop();
resolved.insert(name.clone(), requirements);
resolved.insert(
name.clone(),
FlatDependencyGroup {
requirements,
requires_python: if requires_python_intersection.is_empty() {
None
} else {
Some(requires_python_intersection)
},
},
);
Ok(())
}
// Validate the settings
for (group_name, ..) in settings {
if !groups.contains_key(group_name) {
return Err(DependencyGroupErrorInner::SettingsGroupNotFound(
group_name.clone(),
));
}
}
let mut resolved = BTreeMap::new();
for name in groups.keys() {
let mut parents = Vec::new();
resolve_group(&mut resolved, groups, name, &mut parents)?;
resolve_group(&mut resolved, groups, settings, name, &mut parents)?;
}
Ok(Self(resolved))
}
/// Return the requirements for a given group, if any.
pub fn get(
&self,
group: &GroupName,
) -> Option<&Vec<uv_pep508::Requirement<VerbatimParsedUrl>>> {
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<GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>> {
pub fn entry(&mut self, group: GroupName) -> Entry<GroupName, FlatDependencyGroup> {
self.0.entry(group)
}
/// Consume the [`FlatDependencyGroups`] and return the inner map.
pub fn into_inner(self) -> BTreeMap<GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>> {
pub fn into_inner(self) -> BTreeMap<GroupName, FlatDependencyGroup> {
self.0
}
}
impl FromIterator<(GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>)>
for FlatDependencyGroups
{
fn from_iter<
T: IntoIterator<Item = (GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>)>,
>(
iter: T,
) -> Self {
impl FromIterator<(GroupName, FlatDependencyGroup)> for FlatDependencyGroups {
fn from_iter<T: IntoIterator<Item = (GroupName, FlatDependencyGroup)>>(iter: T) -> Self {
Self(iter.into_iter().collect())
}
}
impl IntoIterator for FlatDependencyGroups {
type Item = (GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>);
type IntoIter = std::collections::btree_map::IntoIter<
GroupName,
Vec<uv_pep508::Requirement<VerbatimParsedUrl>>,
>;
type Item = (GroupName, FlatDependencyGroup);
type IntoIter = std::collections::btree_map::IntoIter<GroupName, FlatDependencyGroup>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
@ -142,7 +252,24 @@ impl IntoIterator for FlatDependencyGroups {
}
#[derive(Debug, Error)]
pub enum DependencyGroupError {
#[error("{} has malformed dependency groups", if path.is_empty() && package.is_empty() {
"Project".to_string()
} else if path.is_empty() {
format!("Project `{package}`")
} else if package.is_empty() {
format!("`{path}`")
} else {
format!("Project `{package} @ {path}`")
})]
pub struct DependencyGroupError {
package: String,
path: String,
#[source]
error: DependencyGroupErrorInner,
}
#[derive(Debug, Error)]
pub enum DependencyGroupErrorInner {
#[error("Failed to parse entry in group `{0}`: `{1}`")]
GroupParseError(
GroupName,
@ -159,9 +286,15 @@ pub enum DependencyGroupError {
DependencyGroupCycle(Cycle),
#[error("Group `{0}` contains an unknown dependency object specifier: {1:?}")]
DependencyObjectSpecifierNotSupported(GroupName, BTreeMap<String, String>),
#[error("Failed to find group `{0}` specified in `[tool.uv.dependency-groups]`")]
SettingsGroupNotFound(GroupName),
#[error(
"`[tool.uv.dependency-groups]` specifies the `dev` group, but only `tool.uv.dev-dependencies` was found. To reference the `dev` group, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead."
)]
SettingsDevGroupInclude,
}
impl DependencyGroupError {
impl DependencyGroupErrorInner {
/// Enrich a [`DependencyGroupError`] with the `tool.uv.dev-dependencies` metadata, if applicable.
#[must_use]
pub fn with_dev_dependencies(
@ -169,10 +302,15 @@ impl DependencyGroupError {
dev_dependencies: Option<&Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
) -> Self {
match self {
DependencyGroupError::GroupNotFound(group, parent)
Self::GroupNotFound(group, parent)
if dev_dependencies.is_some() && group == *DEV_DEPENDENCIES =>
{
DependencyGroupError::DevGroupInclude(parent)
Self::DevGroupInclude(parent)
}
Self::SettingsGroupNotFound(group)
if dev_dependencies.is_some() && group == *DEV_DEPENDENCIES =>
{
Self::SettingsDevGroupInclude
}
_ => self,
}

View file

@ -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;

View file

@ -353,6 +353,24 @@ pub struct ToolUv {
)]
pub default_groups: Option<DefaultGroups>,
/// Additional settings for `dependency-groups`.
///
/// Currently this can only be used to add `requires-python` constraints
/// to dependency groups (typically to inform uv that your dev tooling
/// has a higher python requirement than your actual project).
///
/// This cannot be used to define dependency groups, use the top-level
/// `[dependency-groups]` table for that.
#[option(
default = "[]",
value_type = "dict",
example = r#"
[tool.uv.dependency-groups]
my-group = {requires-python = ">=3.12"}
"#
)]
pub dependency_groups: Option<ToolUvDependencyGroups>,
/// The project's development dependencies.
///
/// Development dependencies will be installed by default in `uv run` and `uv sync`, but will
@ -653,6 +671,77 @@ impl<'de> serde::de::Deserialize<'de> for ToolUvSources {
}
}
#[derive(Default, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(Serialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ToolUvDependencyGroups(BTreeMap<GroupName, DependencyGroupSettings>);
impl ToolUvDependencyGroups {
/// Returns the underlying `BTreeMap` of group names to settings.
pub fn inner(&self) -> &BTreeMap<GroupName, DependencyGroupSettings> {
&self.0
}
/// Convert the [`ToolUvDependencyGroups`] into its inner `BTreeMap`.
#[must_use]
pub fn into_inner(self) -> BTreeMap<GroupName, DependencyGroupSettings> {
self.0
}
}
/// Ensure that all keys in the TOML table are unique.
impl<'de> serde::de::Deserialize<'de> for ToolUvDependencyGroups {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<M>(self, mut access: M) -> Result<Self::Value, M::Error>
where
M: serde::de::MapAccess<'de>,
{
let mut groups = BTreeMap::new();
while let Some((key, value)) =
access.next_entry::<GroupName, DependencyGroupSettings>()?
{
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<String>"))]
pub requires_python: Option<VersionSpecifiers>,
}
#[derive(Deserialize, OptionsMetadata, Default, Debug, Clone, PartialEq, Eq)]
#[cfg_attr(test, derive(Serialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]

View file

@ -8,6 +8,7 @@ use glob::{GlobError, PatternError, glob};
use rustc_hash::{FxHashMap, FxHashSet};
use tracing::{debug, trace, warn};
use uv_configuration::DependencyGroupsWithDefaults;
use uv_distribution_types::{Index, Requirement, RequirementSource};
use uv_fs::{CWD, Simplified};
use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
@ -17,7 +18,7 @@ use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl};
use uv_static::EnvVars;
use uv_warnings::warn_user_once;
use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroups};
use crate::dependency_groups::{DependencyGroupError, FlatDependencyGroup, FlatDependencyGroups};
use crate::pyproject::{
Project, PyProjectToml, PyprojectTomlError, Sources, ToolUvSources, ToolUvWorkspace,
};
@ -95,6 +96,8 @@ pub struct DiscoveryOptions {
pub members: MemberDiscovery,
}
pub type RequiresPythonSources = BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>;
/// A workspace, consisting of a root directory and members. See [`ProjectWorkspace`].
#[derive(Debug, Clone)]
#[cfg_attr(test, derive(serde::Serialize))]
@ -413,15 +416,44 @@ impl Workspace {
}
/// Returns an iterator over the `requires-python` values for each member of the workspace.
pub fn requires_python(&self) -> impl Iterator<Item = (&PackageName, &VersionSpecifiers)> {
self.packages().iter().filter_map(|(name, member)| {
member
pub fn requires_python(
&self,
groups: &DependencyGroupsWithDefaults,
) -> Result<RequiresPythonSources, DependencyGroupError> {
let mut requires = RequiresPythonSources::new();
for (name, member) in self.packages() {
// Get the top-level requires-python for this package, which is always active
//
// Arguably we could check groups.prod() to disable this, since, the requires-python
// of the project is *technically* not relevant if you're doing `--only-group`, but,
// that would be a big surprising change so let's *not* do that until someone asks!
let top_requires = member
.pyproject_toml()
.project
.as_ref()
.and_then(|project| project.requires_python.as_ref())
.map(|requires_python| (name, requires_python))
})
.map(|requires_python| ((name.to_owned(), None), requires_python.clone()));
requires.extend(top_requires);
// Get the requires-python for each enabled group on this package
// We need to do full flattening here because include-group can transfer requires-python
let dependency_groups =
FlatDependencyGroups::from_pyproject_toml(member.root(), &member.pyproject_toml)?;
let group_requires =
dependency_groups
.into_iter()
.filter_map(move |(group_name, flat_group)| {
if groups.contains(&group_name) {
flat_group.requires_python.map(|requires_python| {
((name.to_owned(), Some(group_name)), requires_python)
})
} else {
None
}
});
requires.extend(group_requires);
}
Ok(requires)
}
/// Returns any requirements that are exclusive to the workspace root, i.e., not included in
@ -439,12 +471,9 @@ impl Workspace {
/// corresponding `pyproject.toml`.
///
/// Otherwise, returns an empty list.
pub fn dependency_groups(
pub fn workspace_dependency_groups(
&self,
) -> Result<
BTreeMap<GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
DependencyGroupError,
> {
) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
if self
.packages
.values()
@ -455,35 +484,10 @@ impl Workspace {
Ok(BTreeMap::default())
} else {
// Otherwise, return the dependency groups in the non-project workspace root.
// First, collect `tool.uv.dev_dependencies`
let dev_dependencies = self
.pyproject_toml
.tool
.as_ref()
.and_then(|tool| tool.uv.as_ref())
.and_then(|uv| uv.dev_dependencies.as_ref());
// Then, collect `dependency-groups`
let dependency_groups = self
.pyproject_toml
.dependency_groups
.iter()
.flatten()
.collect::<BTreeMap<_, _>>();
// Flatten the dependency groups.
let mut dependency_groups =
FlatDependencyGroups::from_dependency_groups(&dependency_groups)
.map_err(|err| err.with_dev_dependencies(dev_dependencies))?;
// Add the `dev` group, if `dev-dependencies` is defined.
if let Some(dev_dependencies) = dev_dependencies {
dependency_groups
.entry(DEV_DEPENDENCIES.clone())
.or_insert_with(Vec::new)
.extend(dev_dependencies.clone());
}
let dependency_groups = FlatDependencyGroups::from_pyproject_toml(
&self.install_path,
&self.pyproject_toml,
)?;
Ok(dependency_groups.into_inner())
}
}
@ -1818,6 +1822,7 @@ mod tests {
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"constraint-dependencies": null,
@ -1913,6 +1918,7 @@ mod tests {
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"constraint-dependencies": null,
@ -2123,6 +2129,7 @@ mod tests {
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"constraint-dependencies": null,
@ -2230,6 +2237,7 @@ mod tests {
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"constraint-dependencies": null,
@ -2350,6 +2358,7 @@ mod tests {
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"constraint-dependencies": null,
@ -2444,6 +2453,7 @@ mod tests {
"managed": null,
"package": null,
"default-groups": null,
"dependency-groups": null,
"dev-dependencies": null,
"override-dependencies": null,
"constraint-dependencies": null,

View file

@ -16,13 +16,16 @@ use uv_cache::{Cache, CacheBucket};
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
BuildKind, BuildOptions, BuildOutput, Concurrency, ConfigSettings, Constraints,
HashCheckingMode, IndexStrategy, KeyringProviderType, PreviewMode, SourceStrategy,
DependencyGroupsWithDefaults, HashCheckingMode, IndexStrategy, KeyringProviderType,
PreviewMode, SourceStrategy,
};
use uv_dispatch::{BuildDispatch, SharedState};
use uv_distribution_filename::{
DistFilename, SourceDistExtension, SourceDistFilename, WheelFilename,
};
use uv_distribution_types::{DependencyMetadata, Index, IndexLocations, SourceDist};
use uv_distribution_types::{
DependencyMetadata, Index, IndexLocations, RequiresPython, SourceDist,
};
use uv_fs::{Simplified, relative_to};
use uv_install_wheel::LinkMode;
use uv_normalize::PackageName;
@ -33,7 +36,7 @@ use uv_python::{
VersionRequest,
};
use uv_requirements::RequirementsSource;
use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython};
use uv_resolver::{ExcludeNewer, FlatIndex};
use uv_settings::PythonInstallMirrors;
use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy};
use uv_workspace::{DiscoveryOptions, Workspace, WorkspaceCache, WorkspaceError};
@ -471,7 +474,8 @@ async fn build_package(
// (3) `Requires-Python` in `pyproject.toml`
if interpreter_request.is_none() {
if let Ok(workspace) = workspace {
interpreter_request = find_requires_python(workspace)?
let groups = DependencyGroupsWithDefaults::none();
interpreter_request = find_requires_python(workspace, &groups)?
.as_ref()
.map(RequiresPython::specifiers)
.map(|specifiers| {

View file

@ -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};

View file

@ -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.

View file

@ -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;

View file

@ -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;

View file

@ -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<String>,
tag: Option<String>,
branch: Option<String>,
extras: Vec<ExtraName>,
extras_of_dependency: Vec<ExtraName>,
package: Option<PackageName>,
python: Option<String>,
install_mirrors: PythonInstallMirrors,
@ -122,6 +123,34 @@ pub(crate) async fn add(
let reporter = PythonDownloadReporter::single(printer);
// Determine what defaults/extras we're explicitly enabling
let (extras, groups) = match &dependency_type {
DependencyType::Production => {
let extras = ExtrasSpecification::from_extra(vec![]);
let groups = DependencyGroups::from_dev_mode(DevMode::Exclude);
(extras, groups)
}
DependencyType::Dev => {
let extras = ExtrasSpecification::from_extra(vec![]);
let groups = DependencyGroups::from_dev_mode(DevMode::Include);
(extras, groups)
}
DependencyType::Optional(extra_name) => {
let extras = ExtrasSpecification::from_extra(vec![extra_name.clone()]);
let groups = DependencyGroups::from_dev_mode(DevMode::Exclude);
(extras, groups)
}
DependencyType::Group(group_name) => {
let extras = ExtrasSpecification::from_extra(vec![]);
let groups = DependencyGroups::from_group(group_name.clone());
(extras, groups)
}
};
// Default extras currently always disabled
let defaulted_extras = extras.with_defaults(DefaultExtras::default());
// Default groups we need the actual project for, interpreter discovery will use this!
let defaulted_groups;
let target = if let Some(script) = script {
// If we found a PEP 723 script and the user provided a project-only setting, warn.
if package.is_some() {
@ -172,6 +201,9 @@ pub(crate) async fn add(
}
};
// Scripts don't actually have groups
defaulted_groups = groups.with_defaults(DefaultGroups::default());
// Discover the interpreter.
let interpreter = ScriptInterpreter::discover(
Pep723ItemRef::Script(&script),
@ -234,11 +266,16 @@ pub(crate) async fn add(
}
}
// Enable the default groups of the project
defaulted_groups =
groups.with_defaults(default_dependency_groups(project.pyproject_toml())?);
if frozen || no_sync {
// Discover the interpreter.
let interpreter = ProjectInterpreter::discover(
project.workspace(),
project_dir,
&defaulted_groups,
python.as_deref().map(PythonRequest::parse),
&network_settings,
python_preference,
@ -258,6 +295,7 @@ pub(crate) async fn add(
// Discover or create the virtual environment.
let environment = ProjectEnvironment::get_or_init(
project.workspace(),
&defaulted_groups,
python.as_deref().map(PythonRequest::parse),
&install_mirrors,
&network_settings,
@ -468,7 +506,7 @@ pub(crate) async fn add(
rev.as_deref(),
tag.as_deref(),
branch.as_deref(),
&extras,
&extras_of_dependency,
index,
&mut toml,
)?;
@ -551,7 +589,8 @@ pub(crate) async fn add(
lock_state,
sync_state,
locked,
&dependency_type,
&defaulted_extras,
&defaulted_groups,
raw,
bounds,
constraints,
@ -778,7 +817,8 @@ async fn lock_and_sync(
lock_state: UniversalState,
sync_state: PlatformState,
locked: bool,
dependency_type: &DependencyType,
extras: &ExtrasSpecificationWithDefaults,
groups: &DependencyGroupsWithDefaults,
raw: bool,
bound_kind: Option<AddBoundsKind>,
constraints: Vec<NameRequirementSpecification>,
@ -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,

View file

@ -61,7 +61,7 @@ pub(crate) async fn export(
install_options: InstallOptions,
output_file: Option<PathBuf>,
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,

View file

@ -4,13 +4,15 @@ use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::str::FromStr;
use uv_distribution_types::RequiresPython;
use tracing::{debug, trace, warn};
use uv_cache::Cache;
use uv_cli::AuthorFrom;
use uv_client::BaseClientBuilder;
use uv_configuration::{
PreviewMode, ProjectBuildBackend, VersionControlError, VersionControlSystem,
DependencyGroupsWithDefaults, PreviewMode, ProjectBuildBackend, VersionControlError,
VersionControlSystem,
};
use uv_fs::{CWD, Simplified};
use uv_git::GIT;
@ -21,7 +23,6 @@ use uv_python::{
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions,
VersionRequest,
};
use uv_resolver::RequiresPython;
use uv_scripts::{Pep723Script, ScriptTag};
use uv_settings::PythonInstallMirrors;
use uv_static::EnvVars;
@ -502,7 +503,7 @@ async fn init_project(
(requires_python, python_request)
} else if let Some(requires_python) = workspace
.as_ref()
.map(find_requires_python)
.map(|workspace| find_requires_python(workspace, &DependencyGroupsWithDefaults::none()))
.transpose()?
.flatten()
{

View file

@ -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::<FxHashSet<_>>();
for group in groups.explicit_names() {

View file

@ -12,13 +12,14 @@ use tracing::debug;
use uv_cache::Cache;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
Concurrency, Constraints, DryRun, ExtrasSpecification, PreviewMode, Reinstall, Upgrade,
Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification,
PreviewMode, Reinstall, Upgrade,
};
use uv_dispatch::BuildDispatch;
use uv_distribution::DistributionDatabase;
use uv_distribution_types::{
DependencyMetadata, HashGeneration, Index, IndexLocations, NameRequirementSpecification,
Requirement, UnresolvedRequirementSpecification,
Requirement, RequiresPython, UnresolvedRequirementSpecification,
};
use uv_git::ResolvedRepositoryReference;
use uv_normalize::{GroupName, PackageName};
@ -28,7 +29,7 @@ use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreferenc
use uv_requirements::ExtrasResolver;
use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements};
use uv_resolver::{
FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, RequiresPython,
FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement,
ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker,
};
use uv_scripts::{Pep723ItemRef, Pep723Script};
@ -142,6 +143,8 @@ pub(crate) async fn lock(
LockTarget::Workspace(workspace) => ProjectInterpreter::discover(
workspace,
project_dir,
// Don't enable any groups' requires-python for interpreter discovery
&DependencyGroupsWithDefaults::none(),
python.as_deref().map(PythonRequest::parse),
&network_settings,
python_preference,
@ -437,8 +440,8 @@ async fn do_lock(
let build_constraints = target.lower(build_constraints, index_locations, *sources)?;
let dependency_groups = dependency_groups
.into_iter()
.map(|(name, requirements)| {
let requirements = target.lower(requirements, index_locations, *sources)?;
.map(|(name, group)| {
let requirements = target.lower(group.requirements, index_locations, *sources)?;
Ok((name, requirements))
})
.collect::<Result<BTreeMap<_, _>, ProjectError>>()?;

View file

@ -3,15 +3,15 @@ use std::path::{Path, PathBuf};
use itertools::Either;
use uv_configuration::SourceStrategy;
use uv_configuration::{DependencyGroupsWithDefaults, SourceStrategy};
use uv_distribution::LoweredRequirement;
use uv_distribution_types::{Index, IndexLocations, Requirement};
use uv_distribution_types::{Index, IndexLocations, Requirement, RequiresPython};
use uv_normalize::{GroupName, PackageName};
use uv_pep508::RequirementOrigin;
use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl};
use uv_resolver::{Lock, LockVersion, RequiresPython, VERSION};
use uv_resolver::{Lock, LockVersion, VERSION};
use uv_scripts::Pep723Script;
use uv_workspace::dependency_groups::DependencyGroupError;
use uv_workspace::dependency_groups::{DependencyGroupError, FlatDependencyGroup};
use uv_workspace::{Workspace, WorkspaceMember};
use crate::commands::project::{ProjectError, find_requires_python};
@ -100,12 +100,9 @@ impl<'lock> LockTarget<'lock> {
/// attached to any members within the target.
pub(crate) fn dependency_groups(
self,
) -> Result<
BTreeMap<GroupName, Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
DependencyGroupError,
> {
) -> Result<BTreeMap<GroupName, FlatDependencyGroup>, DependencyGroupError> {
match self {
Self::Workspace(workspace) => workspace.dependency_groups(),
Self::Workspace(workspace) => workspace.workspace_dependency_groups(),
Self::Script(_) => Ok(BTreeMap::new()),
}
}
@ -219,7 +216,11 @@ impl<'lock> LockTarget<'lock> {
#[allow(clippy::result_large_err)]
pub(crate) fn requires_python(self) -> Result<Option<RequiresPython>, ProjectError> {
match self {
Self::Workspace(workspace) => find_requires_python(workspace),
Self::Workspace(workspace) => {
// When locking, don't try to enforce requires-python bounds that appear on groups
let groups = DependencyGroupsWithDefaults::none();
find_requires_python(workspace, &groups)
}
Self::Script(script) => Ok(script
.metadata
.requires_python

View file

@ -18,7 +18,8 @@ use uv_configuration::{
use uv_dispatch::{BuildDispatch, SharedState};
use uv_distribution::{DistributionDatabase, LoweredRequirement};
use uv_distribution_types::{
Index, Requirement, Resolution, UnresolvedRequirement, UnresolvedRequirementSpecification,
Index, Requirement, RequiresPython, Resolution, UnresolvedRequirement,
UnresolvedRequirementSpecification,
};
use uv_fs::{CWD, LockedFile, Simplified};
use uv_git::ResolvedRepositoryReference;
@ -35,8 +36,8 @@ use uv_python::{
use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements};
use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification};
use uv_resolver::{
FlatIndex, Lock, OptionsBuilder, Preference, PythonRequirement, RequiresPython,
ResolverEnvironment, ResolverOutput,
FlatIndex, Lock, OptionsBuilder, Preference, PythonRequirement, ResolverEnvironment,
ResolverOutput,
};
use uv_scripts::Pep723ItemRef;
use uv_settings::PythonInstallMirrors;
@ -45,7 +46,7 @@ use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::dependency_groups::DependencyGroupError;
use uv_workspace::pyproject::PyProjectToml;
use uv_workspace::{Workspace, WorkspaceCache};
use uv_workspace::{RequiresPythonSources, Workspace, WorkspaceCache};
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
use crate::commands::pip::operations::{Changelog, Modifications};
@ -108,19 +109,28 @@ pub(crate) enum ProjectError {
Conflict(#[from] ConflictError),
#[error(
"The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`"
"The requested interpreter resolved to Python {_0}, which is incompatible with the project's Python requirement: `{_1}`{}",
format_optional_requires_python_sources(_2, *_3)
)]
RequestedPythonProjectIncompatibility(Version, RequiresPython),
RequestedPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources, bool),
#[error(
"The Python request from `{0}` resolved to Python {1}, which is incompatible with the project's Python requirement: `{2}`. Use `uv python pin` to update the `.python-version` file to a compatible version."
"The Python request from `{_0}` resolved to Python {_1}, which is incompatible with the project's Python requirement: `{_2}`{}\nUse `uv python pin` to update the `.python-version` file to a compatible version",
format_optional_requires_python_sources(_3, *_4)
)]
DotPythonVersionProjectIncompatibility(String, Version, RequiresPython),
DotPythonVersionProjectIncompatibility(
String,
Version,
RequiresPython,
RequiresPythonSources,
bool,
),
#[error(
"The resolved Python interpreter (Python {0}) is incompatible with the project's Python requirement: `{1}`"
"The resolved Python interpreter (Python {_0}) is incompatible with the project's Python requirement: `{_1}`{}",
format_optional_requires_python_sources(_2, *_3)
)]
RequiresPythonProjectIncompatibility(Version, RequiresPython),
RequiresPythonProjectIncompatibility(Version, RequiresPython, RequiresPythonSources, bool),
#[error(
"The requested interpreter resolved to Python {0}, which is incompatible with the script's Python requirement: `{1}`"
@ -137,34 +147,6 @@ pub(crate) enum ProjectError {
)]
RequiresPythonScriptIncompatibility(Version, RequiresPython),
#[error("The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`. However, a workspace member (`{member}`) supports Python {3}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _2.cyan(), venv = format!("uv venv --python {_0}").green(), install = "uv pip install -e .".green(), path = _4.user_display().cyan() )]
RequestedMemberIncompatibility(
Version,
RequiresPython,
PackageName,
VersionSpecifiers,
PathBuf,
),
#[error("The Python request from `{0}` resolved to Python {1}, which is incompatible with the project's Python requirement: `{2}`. However, a workspace member (`{member}`) supports Python {4}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _3.cyan(), venv = format!("uv venv --python {_1}").green(), install = "uv pip install -e .".green(), path = _5.user_display().cyan() )]
DotPythonVersionMemberIncompatibility(
String,
Version,
RequiresPython,
PackageName,
VersionSpecifiers,
PathBuf,
),
#[error("The resolved Python interpreter (Python {0}) is incompatible with the project's Python requirement: `{1}`. However, a workspace member (`{member}`) supports Python {3}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _2.cyan(), venv = format!("uv venv --python {_0}").green(), install = "uv pip install -e .".green(), path = _4.user_display().cyan() )]
RequiresPythonMemberIncompatibility(
Version,
RequiresPython,
PackageName,
VersionSpecifiers,
PathBuf,
),
#[error("Group `{0}` is not defined in the project's `dependency-groups` table")]
MissingGroupProject(GroupName),
@ -194,8 +176,11 @@ pub(crate) enum ProjectError {
#[error("Environment markers `{0}` don't overlap with Python requirement `{1}`")]
DisjointEnvironment(MarkerTreeContents, VersionSpecifiers),
#[error("The workspace contains conflicting Python requirements:\n{}", _0.iter().map(|(name, specifiers)| format!("- `{name}`: `{specifiers}`")).join("\n"))]
DisjointRequiresPython(BTreeMap<PackageName, VersionSpecifiers>),
#[error(
"Found conflicting Python requirements:\n{}",
format_requires_python_sources(_0)
)]
DisjointRequiresPython(BTreeMap<(PackageName, Option<GroupName>), VersionSpecifiers>),
#[error("Environment marker is empty")]
EmptyEnvironment,
@ -286,7 +271,7 @@ pub(crate) struct ConflictError {
/// The items from the set that were enabled, and thus create the conflict.
pub(crate) conflicts: Vec<ConflictPackage>,
/// Enabled dependency groups with defaults applied.
pub(crate) dev: DependencyGroupsWithDefaults,
pub(crate) groups: DependencyGroupsWithDefaults,
}
impl std::fmt::Display for ConflictError {
@ -338,7 +323,7 @@ impl std::fmt::Display for ConflictError {
.iter()
.map(|conflict| match conflict {
ConflictPackage::Group(group)
if self.dev.contains_because_default(group) =>
if self.groups.contains_because_default(group) =>
format!("`{group}` (enabled by default)"),
ConflictPackage::Group(group) => format!("`{group}`"),
ConflictPackage::Extra(..) => unreachable!(),
@ -358,7 +343,7 @@ impl std::fmt::Display for ConflictError {
let conflict = match conflict {
ConflictPackage::Extra(extra) => format!("extra `{extra}`"),
ConflictPackage::Group(group)
if self.dev.contains_because_default(group) =>
if self.groups.contains_because_default(group) =>
{
format!("group `{group}` (enabled by default)")
}
@ -429,23 +414,16 @@ impl PlatformState {
#[allow(clippy::result_large_err)]
pub(crate) fn find_requires_python(
workspace: &Workspace,
groups: &DependencyGroupsWithDefaults,
) -> Result<Option<RequiresPython>, ProjectError> {
let requires_python = workspace.requires_python(groups)?;
// If there are no `Requires-Python` specifiers in the workspace, return `None`.
if workspace.requires_python().next().is_none() {
if requires_python.is_empty() {
return Ok(None);
}
match RequiresPython::intersection(
workspace
.requires_python()
.map(|(.., specifiers)| specifiers),
) {
match RequiresPython::intersection(requires_python.iter().map(|(.., specifiers)| specifiers)) {
Some(requires_python) => Ok(Some(requires_python)),
None => Err(ProjectError::DisjointRequiresPython(
workspace
.requires_python()
.map(|(name, specifiers)| (name.clone(), specifiers.clone()))
.collect(),
)),
None => Err(ProjectError::DisjointRequiresPython(requires_python)),
}
}
@ -457,6 +435,7 @@ pub(crate) fn find_requires_python(
pub(crate) fn validate_project_requires_python(
interpreter: &Interpreter,
workspace: Option<&Workspace>,
groups: &DependencyGroupsWithDefaults,
requires_python: &RequiresPython,
source: &PythonRequestSource,
) -> Result<(), ProjectError> {
@ -464,57 +443,24 @@ pub(crate) fn validate_project_requires_python(
return Ok(());
}
// If the Python version is compatible with one of the workspace _members_, raise
// a dedicated error. For example, if the workspace root requires Python >=3.12, but
// a library in the workspace is compatible with Python >=3.8, the user may attempt
// to sync on Python 3.8. This will fail, but we should provide a more helpful error
// message.
for (name, member) in workspace.into_iter().flat_map(Workspace::packages) {
let Some(project) = member.pyproject_toml().project.as_ref() else {
continue;
};
let Some(specifiers) = project.requires_python.as_ref() else {
continue;
};
if specifiers.contains(interpreter.python_version()) {
return match source {
PythonRequestSource::UserRequest => {
Err(ProjectError::RequestedMemberIncompatibility(
interpreter.python_version().clone(),
requires_python.clone(),
name.clone(),
specifiers.clone(),
member.root().clone(),
))
}
PythonRequestSource::DotPythonVersion(file) => {
Err(ProjectError::DotPythonVersionMemberIncompatibility(
file.path().user_display().to_string(),
interpreter.python_version().clone(),
requires_python.clone(),
name.clone(),
specifiers.clone(),
member.root().clone(),
))
}
PythonRequestSource::RequiresPython => {
Err(ProjectError::RequiresPythonMemberIncompatibility(
interpreter.python_version().clone(),
requires_python.clone(),
name.clone(),
specifiers.clone(),
member.root().clone(),
))
}
};
}
}
// Find all the individual requires_python constraints that conflict
let conflicting_requires = workspace
.and_then(|workspace| workspace.requires_python(groups).ok())
.into_iter()
.flatten()
.filter(|(.., requires)| !requires.contains(interpreter.python_version()))
.collect::<RequiresPythonSources>();
let workspace_non_trivial = workspace
.map(|workspace| workspace.packages().len() > 1)
.unwrap_or(false);
match source {
PythonRequestSource::UserRequest => {
Err(ProjectError::RequestedPythonProjectIncompatibility(
interpreter.python_version().clone(),
requires_python.clone(),
conflicting_requires,
workspace_non_trivial,
))
}
PythonRequestSource::DotPythonVersion(file) => {
@ -522,12 +468,16 @@ pub(crate) fn validate_project_requires_python(
file.path().user_display().to_string(),
interpreter.python_version().clone(),
requires_python.clone(),
conflicting_requires,
workspace_non_trivial,
))
}
PythonRequestSource::RequiresPython => {
Err(ProjectError::RequiresPythonProjectIncompatibility(
interpreter.python_version().clone(),
requires_python.clone(),
conflicting_requires,
workspace_non_trivial,
))
}
}
@ -738,7 +688,13 @@ impl ScriptInterpreter {
if let Err(err) = match requires_python {
Some((requires_python, RequiresPythonSource::Project)) => {
validate_project_requires_python(&interpreter, workspace, &requires_python, &source)
validate_project_requires_python(
&interpreter,
workspace,
&DependencyGroupsWithDefaults::none(),
&requires_python,
&source,
)
}
Some((requires_python, RequiresPythonSource::Script)) => {
validate_script_requires_python(&interpreter, &requires_python, &source)
@ -874,6 +830,7 @@ impl ProjectInterpreter {
pub(crate) async fn discover(
workspace: &Workspace,
project_dir: &Path,
groups: &DependencyGroupsWithDefaults,
python_request: Option<PythonRequest>,
network_settings: &NetworkSettings,
python_preference: PythonPreference,
@ -890,8 +847,14 @@ impl ProjectInterpreter {
source,
python_request,
requires_python,
} = WorkspacePython::from_request(python_request, Some(workspace), project_dir, no_config)
.await?;
} = WorkspacePython::from_request(
python_request,
Some(workspace),
groups,
project_dir,
no_config,
)
.await?;
// Read from the virtual environment first.
let root = workspace.venv(active);
@ -1002,6 +965,7 @@ impl ProjectInterpreter {
validate_project_requires_python(
&interpreter,
Some(workspace),
groups,
requires_python,
&source,
)?;
@ -1081,10 +1045,14 @@ impl WorkspacePython {
pub(crate) async fn from_request(
python_request: Option<PythonRequest>,
workspace: Option<&Workspace>,
groups: &DependencyGroupsWithDefaults,
project_dir: &Path,
no_config: bool,
) -> Result<Self, ProjectError> {
let requires_python = workspace.map(find_requires_python).transpose()?.flatten();
let requires_python = workspace
.map(|workspace| find_requires_python(workspace, groups))
.transpose()?
.flatten();
let workspace_root = workspace.map(Workspace::install_path);
@ -1165,6 +1133,8 @@ impl ScriptPython {
} = WorkspacePython::from_request(
python_request,
workspace,
// Scripts have no groups to hang requires-python settings off of
&DependencyGroupsWithDefaults::none(),
script.path().and_then(Path::parent).unwrap_or(&**CWD),
no_config,
)
@ -1231,6 +1201,7 @@ impl ProjectEnvironment {
/// Initialize a virtual environment for the current project.
pub(crate) async fn get_or_init(
workspace: &Workspace,
groups: &DependencyGroupsWithDefaults,
python: Option<PythonRequest>,
install_mirrors: &PythonInstallMirrors,
network_settings: &NetworkSettings,
@ -1249,6 +1220,7 @@ impl ProjectEnvironment {
match ProjectInterpreter::discover(
workspace,
workspace.install_path().as_ref(),
groups,
python,
network_settings,
python_preference,
@ -2434,7 +2406,7 @@ pub(crate) fn default_dependency_groups(
pub(crate) fn detect_conflicts(
lock: &Lock,
extras: &ExtrasSpecification,
dev: &DependencyGroupsWithDefaults,
groups: &DependencyGroupsWithDefaults,
) -> Result<(), ProjectError> {
// Note that we need to collect all extras and groups that match in
// a particular set, since extras can be declared as conflicting with
@ -2453,7 +2425,7 @@ pub(crate) fn detect_conflicts(
}
if item
.group()
.map(|group| dev.contains(group))
.map(|group| groups.contains(group))
.unwrap_or(false)
{
conflicts.push(item.conflict().clone());
@ -2463,7 +2435,7 @@ pub(crate) fn detect_conflicts(
return Err(ProjectError::Conflict(ConflictError {
set: set.clone(),
conflicts,
dev: dev.clone(),
groups: groups.clone(),
}));
}
}
@ -2677,6 +2649,50 @@ fn cache_name(name: &str) -> Option<Cow<'_, str>> {
}
}
fn format_requires_python_sources(conflicts: &RequiresPythonSources) -> String {
conflicts
.iter()
.map(|((package, group), specifiers)| {
if let Some(group) = group {
format!("- {package}:{group}: {specifiers}")
} else {
format!("- {package}: {specifiers}")
}
})
.join("\n")
}
fn format_optional_requires_python_sources(
conflicts: &RequiresPythonSources,
workspace_non_trivial: bool,
) -> String {
// If there's lots of conflicts, print a list
if conflicts.len() > 1 {
return format!(
".\nThe following `requires-python` declarations do not permit this version:\n{}",
format_requires_python_sources(conflicts)
);
}
// If there's one conflict, give a clean message
if conflicts.len() == 1 {
let ((package, group), _) = conflicts.iter().next().unwrap();
if let Some(group) = group {
if workspace_non_trivial {
return format!(
" (from workspace member `{package}`'s `tool.uv.dependency-groups.{group}.requires-python`)."
);
}
return format!(" (from `tool.uv.dependency-groups.{group}.requires-python`).");
}
if workspace_non_trivial {
return format!(" (from workspace member `{package}`'s `project.requires-python`).");
}
return " (from `project.requires-python`)".to_owned();
}
// Otherwise don't elaborate
String::new()
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -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,

View file

@ -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<String>,
@ -291,7 +291,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
target,
&environment,
&extras.with_defaults(DefaultExtras::default()),
&dev.with_defaults(DefaultGroups::default()),
&groups.with_defaults(DefaultGroups::default()),
editable,
install_options,
modifications,
@ -468,7 +468,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
if !extras.is_empty() {
warn_user!("Extras are not supported for Python scripts with inline metadata");
}
for flag in dev.history().as_flags_pretty() {
for flag in groups.history().as_flags_pretty() {
warn_user!("`{flag}` is not supported for Python scripts with inline metadata");
}
if all_packages {
@ -543,7 +543,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
for flag in extras.history().as_flags_pretty() {
warn_user!("`{flag}` has no effect when used alongside `--no-project`");
}
for flag in dev.history().as_flags_pretty() {
for flag in groups.history().as_flags_pretty() {
warn_user!("`{flag}` has no effect when used alongside `--no-project`");
}
if locked {
@ -560,7 +560,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
for flag in extras.history().as_flags_pretty() {
warn_user!("`{flag}` has no effect when used outside of a project");
}
for flag in dev.history().as_flags_pretty() {
for flag in groups.history().as_flags_pretty() {
warn_user!("`{flag}` has no effect when used outside of a project");
}
if locked {
@ -583,6 +583,11 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
project.workspace().install_path().display()
);
}
// Determine the groups and extras to include.
let default_groups = default_dependency_groups(project.pyproject_toml())?;
let default_extras = DefaultExtras::default();
let groups = groups.with_defaults(default_groups);
let extras = extras.with_defaults(default_extras);
let venv = if isolated {
debug!("Creating isolated virtual environment");
@ -602,6 +607,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
} = WorkspacePython::from_request(
python.as_deref().map(PythonRequest::parse),
Some(project.workspace()),
&groups,
project_dir,
no_config,
)
@ -626,6 +632,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
validate_project_requires_python(
&interpreter,
Some(project.workspace()),
&groups,
requires_python,
&source,
)?;
@ -647,6 +654,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
// project.
ProjectEnvironment::get_or_init(
project.workspace(),
&groups,
python.as_deref().map(PythonRequest::parse),
&install_mirrors,
&network_settings,
@ -677,14 +685,6 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
.map(|lock| (lock, project.workspace().install_path().to_owned()));
}
} else {
// Validate that any referenced dependency groups are defined in the workspace.
// Determine the default groups to include.
let default_groups = default_dependency_groups(project.pyproject_toml())?;
// Determine the default extras to include.
let default_extras = DefaultExtras::default();
// Determine the lock mode.
let mode = if frozen {
LockMode::Frozen
@ -769,18 +769,15 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
};
let install_options = InstallOptions::default();
let dev = dev.with_defaults(default_groups);
let extras = extras.with_defaults(default_extras);
// Validate that the set of requested extras and development groups are defined in the lockfile.
target.validate_extras(&extras)?;
target.validate_groups(&dev)?;
target.validate_groups(&groups)?;
match project::sync::do_sync(
target,
&venv,
&extras,
&dev,
&groups,
editable,
install_options,
modifications,

View file

@ -57,7 +57,7 @@ pub(crate) async fn sync(
all_packages: bool,
package: Option<PackageName>,
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,
)?;

View file

@ -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,
);

View file

@ -10,8 +10,8 @@ use uv_cache::Cache;
use uv_cli::version::VersionInfo;
use uv_cli::{VersionBump, VersionFormat};
use uv_configuration::{
Concurrency, DependencyGroups, DryRun, EditableMode, ExtrasSpecification, InstallOptions,
PreviewMode,
Concurrency, DependencyGroups, DependencyGroupsWithDefaults, DryRun, EditableMode,
ExtrasSpecification, InstallOptions, PreviewMode,
};
use uv_fs::Simplified;
use uv_normalize::DefaultExtras;
@ -285,6 +285,7 @@ async fn print_frozen_version(
let interpreter = ProjectInterpreter::discover(
project.workspace(),
project_dir,
&DependencyGroupsWithDefaults::none(),
python.as_deref().map(PythonRequest::parse),
&network_settings,
python_preference,
@ -378,12 +379,20 @@ async fn lock_and_sync(
return Ok(ExitStatus::Success);
}
// Determine the groups and extras that should be enabled.
let default_groups = default_dependency_groups(project.pyproject_toml())?;
let default_extras = DefaultExtras::default();
let groups = DependencyGroups::default().with_defaults(default_groups);
let extras = ExtrasSpecification::from_all_extras().with_defaults(default_extras);
let install_options = InstallOptions::default();
// Convert to an `AddTarget` by attaching the appropriate interpreter or environment.
let target = if no_sync {
// Discover the interpreter.
let interpreter = ProjectInterpreter::discover(
project.workspace(),
project_dir,
&groups,
python.as_deref().map(PythonRequest::parse),
&network_settings,
python_preference,
@ -403,6 +412,7 @@ async fn lock_and_sync(
// Discover or create the virtual environment.
let environment = ProjectEnvironment::get_or_init(
project.workspace(),
&groups,
python.as_deref().map(PythonRequest::parse),
&install_mirrors,
&network_settings,
@ -466,15 +476,6 @@ async fn lock_and_sync(
};
// Perform a full sync, because we don't know what exactly is affected by the version.
// TODO(ibraheem): Should we accept CLI overrides for this? Should we even sync here?
let extras = ExtrasSpecification::from_all_extras();
let install_options = InstallOptions::default();
// Determine the default groups to include.
let default_groups = default_dependency_groups(project.pyproject_toml())?;
// Determine the default extras to include.
let default_extras = DefaultExtras::default();
// Identify the installation target.
let target = match &project {
@ -494,8 +495,8 @@ async fn lock_and_sync(
match project::sync::do_sync(
target,
venv,
&extras.with_defaults(default_extras),
&DependencyGroups::default().with_defaults(default_groups),
&extras,
&groups,
EditableMode::Editable,
install_options,
Modifications::Sufficient,

View file

@ -1,6 +1,7 @@
use anyhow::Result;
use std::fmt::Write;
use std::path::Path;
use uv_configuration::DependencyGroupsWithDefaults;
use uv_cache::Cache;
use uv_fs::Simplified;
@ -56,6 +57,8 @@ pub(crate) async fn find(
}
};
// Don't enable the requires-python settings on groups
let groups = DependencyGroupsWithDefaults::none();
let WorkspacePython {
source,
python_request,
@ -63,6 +66,7 @@ pub(crate) async fn find(
} = WorkspacePython::from_request(
request.map(|request| PythonRequest::parse(&request)),
project.as_ref().map(VirtualProject::workspace),
&groups,
project_dir,
no_config,
)
@ -80,6 +84,7 @@ pub(crate) async fn find(
match validate_project_requires_python(
python.interpreter(),
project.as_ref().map(VirtualProject::workspace),
&groups,
&requires_python,
&source,
) {

View file

@ -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")
}
};

View file

@ -13,14 +13,15 @@ use thiserror::Error;
use uv_cache::Cache;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
BuildOptions, Concurrency, ConfigSettings, Constraints, IndexStrategy, KeyringProviderType,
NoBinary, NoBuild, PreviewMode, SourceStrategy,
BuildOptions, Concurrency, ConfigSettings, Constraints, DependencyGroups, IndexStrategy,
KeyringProviderType, NoBinary, NoBuild, PreviewMode, SourceStrategy,
};
use uv_dispatch::{BuildDispatch, SharedState};
use uv_distribution_types::Requirement;
use uv_distribution_types::{DependencyMetadata, Index, IndexLocations};
use uv_fs::Simplified;
use uv_install_wheel::LinkMode;
use uv_normalize::DefaultGroups;
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
};
@ -39,6 +40,8 @@ use crate::commands::reporters::PythonDownloadReporter;
use crate::printer::Printer;
use crate::settings::NetworkSettings;
use super::project::default_dependency_groups;
/// Create a virtual environment.
#[allow(clippy::unnecessary_wraps, clippy::fn_params_excessive_bools)]
pub(crate) async fn venv(
@ -197,6 +200,13 @@ async fn venv_impl(
let reporter = PythonDownloadReporter::single(printer);
// If the default dependency-groups demand a higher requires-python
// we should bias an empty venv to that to avoid churn.
let default_groups = match &project {
Some(project) => default_dependency_groups(project.pyproject_toml()).into_diagnostic()?,
None => DefaultGroups::default(),
};
let groups = DependencyGroups::default().with_defaults(default_groups);
let WorkspacePython {
source,
python_request,
@ -204,6 +214,7 @@ async fn venv_impl(
} = WorkspacePython::from_request(
python_request.map(PythonRequest::parse),
project.as_ref().map(VirtualProject::workspace),
&groups,
project_dir,
no_config,
)
@ -246,6 +257,7 @@ async fn venv_impl(
match validate_project_requires_python(
&interpreter,
project.as_ref().map(VirtualProject::workspace),
&groups,
&requires_python,
&source,
) {

View file

@ -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,

View file

@ -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<String>,
@ -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<PathBuf>,
pub(crate) active: Option<bool>,
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<PackageName>,
pub(crate) prune: Vec<PackageName>,
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,

View file

@ -5259,16 +5259,16 @@ fn lock_requires_python_disjoint() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: The workspace contains conflicting Python requirements:
- `child`: `==3.10`
- `project`: `>=3.12`
"###);
error: Found conflicting Python requirements:
- child: ==3.10
- project: >=3.12
");
Ok(())
}
@ -18298,29 +18298,30 @@ fn lock_request_requires_python() -> Result<()> {
)?;
// Request a version that conflicts with `--requires-python`.
uv_snapshot!(context.filters(), context.lock().arg("--python").arg("3.12"), @r###"
uv_snapshot!(context.filters(), context.lock().arg("--python").arg("3.12"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10`
"###);
error: The requested interpreter resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10` (from `project.requires-python`)
");
// Add a `.python-version` file that conflicts.
let python_version = context.temp_dir.child(".python-version");
python_version.write_str("3.12")?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
error: The Python request from `.python-version` resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10`. Use `uv python pin` to update the `.python-version` file to a compatible version.
"###);
error: The Python request from `.python-version` resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `>=3.8, <=3.10` (from `project.requires-python`)
Use `uv python pin` to update the `.python-version` file to a compatible version
");
Ok(())
}
@ -20660,6 +20661,465 @@ fn lock_group_include() -> Result<()> {
Ok(())
}
#[test]
fn lock_group_requires_python() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["typing-extensions"]
[dependency-groups]
foo = ["idna"]
bar = ["sortedcontainers", "sniffio"]
[tool.uv.dependency-groups]
bar = { requires-python = ">=3.13" }
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
");
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 2
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version < '3.13'",
]
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "typing-extensions" },
]
[package.dev-dependencies]
bar = [
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
foo = [
{ name = "idna" },
]
[package.metadata]
requires-dist = [{ name = "typing-extensions" }]
[package.metadata.requires-dev]
bar = [
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
foo = [{ name = "idna" }]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "typing-extensions"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558, upload-time = "2024-02-25T22:12:49.693Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926, upload-time = "2024-02-25T22:12:47.72Z" },
]
"#
);
});
Ok(())
}
#[test]
fn lock_group_includes_requires_python() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["typing-extensions"]
[dependency-groups]
foo = ["idna", {include-group = "bar"}]
bar = ["sortedcontainers", "sniffio"]
baz = ["idna", {include-group = "bar"}]
blargh = ["idna", {include-group = "bar"}]
[tool.uv.dependency-groups]
bar = { requires-python = ">=3.13" }
baz = { requires-python = ">=3.13.1" }
blargh = { requires-python = ">=3.12.1" }
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
");
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 2
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.13.1'",
"python_full_version >= '3.13' and python_full_version < '3.13.1'",
"python_full_version >= '3.12.[X]' and python_full_version < '3.13'",
"python_full_version < '3.12.[X]'",
]
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "typing-extensions" },
]
[package.dev-dependencies]
bar = [
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
baz = [
{ name = "idna", marker = "python_full_version >= '3.13.1'" },
{ name = "sniffio", marker = "python_full_version >= '3.13.1'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13.1'" },
]
blargh = [
{ name = "idna", marker = "python_full_version >= '3.12.[X]'" },
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
foo = [
{ name = "idna" },
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
[package.metadata]
requires-dist = [{ name = "typing-extensions" }]
[package.metadata.requires-dev]
bar = [
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
baz = [
{ name = "idna", marker = "python_full_version >= '3.13.1'" },
{ name = "sniffio", marker = "python_full_version >= '3.13.1'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13.1'" },
]
blargh = [
{ name = "idna", marker = "python_full_version >= '3.12.[X]'" },
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
foo = [
{ name = "idna" },
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "typing-extensions"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558, upload-time = "2024-02-25T22:12:49.693Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926, upload-time = "2024-02-25T22:12:47.72Z" },
]
"#
);
});
Ok(())
}
/// Referring to a dependency-group with group-requires-python that does not exist
#[test]
fn lock_group_requires_undefined_group() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "myproject"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["typing-extensions"]
[dependency-groups]
bar = ["sortedcontainers"]
[tool.uv.dependency-groups]
foo = { requires-python = ">=3.13" }
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Project `myproject` has malformed dependency groups
Caused by: Failed to find group `foo` specified in `[tool.uv.dependency-groups]`
");
Ok(())
}
/// The legacy dev-dependencies cannot be referred to by group-requires-python
#[test]
fn lock_group_requires_dev_dep() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "myproject"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["typing-extensions"]
[tool.uv]
dev-dependencies = ["sortedcontainers"]
[tool.uv.dependency-groups]
dev = { requires-python = ">=3.13" }
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Project `myproject` has malformed dependency groups
Caused by: `[tool.uv.dependency-groups]` specifies the `dev` group, but only `tool.uv.dev-dependencies` was found. To reference the `dev` group, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead.
");
Ok(())
}
#[test]
fn lock_group_includes_requires_python_contradiction() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["typing-extensions"]
[dependency-groups]
foo = ["idna", {include-group = "bar"}]
bar = ["sortedcontainers", "sniffio"]
[tool.uv.dependency-groups]
bar = { requires-python = ">=3.13" }
foo = { requires-python = "<3.13" }
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 5 packages in [TIME]
");
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 2
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.13'",
"python_full_version < '3.13'",
]
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "idna"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/3f/ea4b9117521a1e9c50344b909be7886dd00a519552724809bb1f486986c2/idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", size = 175426, upload-time = "2023-11-25T15:40:54.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567, upload-time = "2023-11-25T15:40:52.604Z" },
]
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "typing-extensions" },
]
[package.dev-dependencies]
bar = [
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
foo = [
{ name = "idna", marker = "python_full_version < '3.13'" },
]
[package.metadata]
requires-dist = [{ name = "typing-extensions" }]
[package.metadata.requires-dev]
bar = [
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "sortedcontainers", marker = "python_full_version >= '3.13'" },
]
foo = [
{ name = "idna", marker = "python_full_version < '3.13'" },
{ name = "sniffio", marker = "python_version < '0'" },
{ name = "sortedcontainers", marker = "python_version < '0'" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "typing-extensions"
version = "4.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/3a/0d26ce356c7465a19c9ea8814b960f8a36c3b0d07c323176620b7b483e44/typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb", size = 77558, upload-time = "2024-02-25T22:12:49.693Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/de/dc04a3ea60b22624b51c703a84bbe0184abcd1d0b9bc8074b5d6b7ab90bb/typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", size = 33926, upload-time = "2024-02-25T22:12:47.72Z" },
]
"#
);
});
Ok(())
}
#[test]
fn lock_group_include_cycle() -> Result<()> {
let context = TestContext::new("3.12");
@ -20680,15 +21140,15 @@ fn lock_group_include_cycle() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `project @ file://[TEMP_DIR]/`
Detected a cycle in `dependency-groups`: `bar` -> `foobar` -> `foo` -> `bar`
"###);
error: Project `project` has malformed dependency groups
Caused by: Detected a cycle in `dependency-groups`: `bar` -> `foobar` -> `foo` -> `bar`
");
Ok(())
}
@ -20714,15 +21174,15 @@ fn lock_group_include_dev() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r#"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `project @ file://[TEMP_DIR]/`
Group `foo` includes the `dev` group (`include = "dev"`), but only `tool.uv.dev-dependencies` was found. To reference the `dev` group via an `include`, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead.
"###);
error: Project `project` has malformed dependency groups
Caused by: Group `foo` includes the `dev` group (`include = "dev"`), but only `tool.uv.dev-dependencies` was found. To reference the `dev` group via an `include`, remove the `tool.uv.dev-dependencies` section and add any development dependencies to the `dev` entry in the `[dependency-groups]` table instead.
"#);
Ok(())
}
@ -20745,15 +21205,15 @@ fn lock_group_include_missing() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `project @ file://[TEMP_DIR]/`
Failed to find group `bar` included by `foo`
"###);
error: Project `project` has malformed dependency groups
Caused by: Failed to find group `bar` included by `foo`
");
Ok(())
}
@ -20776,31 +21236,31 @@ fn lock_group_invalid_entry_package() -> Result<()> {
"#,
)?;
uv_snapshot!(context.filters(), context.lock(), @r###"
uv_snapshot!(context.filters(), context.lock(), @r#"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `project @ file://[TEMP_DIR]/`
Failed to parse entry in group `foo`: `invalid!`
no such comparison operator "!", must be one of ~= == != <= >= < > ===
invalid!
^
"###);
error: Project `project` has malformed dependency groups
Caused by: Failed to parse entry in group `foo`: `invalid!`
Caused by: no such comparison operator "!", must be one of ~= == != <= >= < > ===
invalid!
^
"#);
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("foo"), @r#"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `project @ file://[TEMP_DIR]/`
Failed to parse entry in group `foo`: `invalid!`
no such comparison operator "!", must be one of ~= == != <= >= < > ===
invalid!
^
"###);
error: Project `project` has malformed dependency groups
Caused by: Failed to parse entry in group `foo`: `invalid!`
Caused by: no such comparison operator "!", must be one of ~= == != <= >= < > ===
invalid!
^
"#);
Ok(())
}
@ -20897,12 +21357,12 @@ fn lock_group_invalid_entry_table() -> Result<()> {
uv_snapshot!(context.filters(), context.lock(), @r#"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `project @ file://[TEMP_DIR]/`
Group `foo` contains an unknown dependency object specifier: {"bar": "unknown"}
error: Project `project` has malformed dependency groups
Caused by: Group `foo` contains an unknown dependency object specifier: {"bar": "unknown"}
"#);
Ok(())

View file

@ -318,15 +318,15 @@ fn python_find_project() {
"###);
// Unless explicitly requested
uv_snapshot!(context.filters(), context.python_find().arg("3.10"), @r###"
uv_snapshot!(context.filters(), context.python_find().arg("3.10"), @r"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.10]
----- stderr -----
warning: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`
"###);
warning: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` (from `project.requires-python`)
");
// Or `--no-project` is used
uv_snapshot!(context.filters(), context.python_find().arg("--no-project"), @r###"
@ -367,15 +367,16 @@ fn python_find_project() {
"###);
// We should warn on subsequent uses, but respect the pinned version?
uv_snapshot!(context.filters(), context.python_find(), @r###"
uv_snapshot!(context.filters(), context.python_find(), @r"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.10]
----- stderr -----
warning: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`. Use `uv python pin` to update the `.python-version` file to a compatible version.
"###);
warning: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` (from `project.requires-python`)
Use `uv python pin` to update the `.python-version` file to a compatible version
");
// Unless the pin file is outside the project, in which case we should just ignore it
let child_dir = context.temp_dir.child("child");

View file

@ -133,7 +133,7 @@ fn run_with_python_version() -> Result<()> {
----- stderr -----
Using CPython 3.9.[X] interpreter at: [PYTHON-3.9]
error: The requested interpreter resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.11, <4`
error: The requested interpreter resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.11, <4` (from `project.requires-python`)
");
Ok(())
@ -3136,25 +3136,27 @@ fn run_isolated_incompatible_python() -> Result<()> {
})?;
// We should reject Python 3.9...
uv_snapshot!(context.filters(), context.run().arg("main.py"), @r###"
uv_snapshot!(context.filters(), context.run().arg("main.py"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.9.[X] interpreter at: [PYTHON-3.9]
error: The Python request from `.python-version` resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12`. Use `uv python pin` to update the `.python-version` file to a compatible version.
"###);
error: The Python request from `.python-version` resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`)
Use `uv python pin` to update the `.python-version` file to a compatible version
");
// ...even if `--isolated` is provided.
uv_snapshot!(context.filters(), context.run().arg("--isolated").arg("main.py"), @r###"
uv_snapshot!(context.filters(), context.run().arg("--isolated").arg("main.py"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: The Python request from `.python-version` resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12`. Use `uv python pin` to update the `.python-version` file to a compatible version.
"###);
error: The Python request from `.python-version` resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`)
Use `uv python pin` to update the `.python-version` file to a compatible version
");
Ok(())
}
@ -4598,6 +4600,249 @@ fn run_default_groups() -> Result<()> {
Ok(())
}
#[test]
fn run_groups_requires_python() -> Result<()> {
let context =
TestContext::new_with_versions(&["3.11", "3.12", "3.13"]).with_filtered_python_sources();
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["typing-extensions"]
[dependency-groups]
foo = ["anyio"]
bar = ["iniconfig"]
dev = ["sniffio"]
[tool.uv.dependency-groups]
foo = {requires-python=">=3.14"}
bar = {requires-python=">=3.13"}
dev = {requires-python=">=3.12"}
"#,
)?;
context.lock().assert().success();
// With --no-default-groups only the main requires-python should be consulted
uv_snapshot!(context.filters(), context.run()
.arg("--no-default-groups")
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
Creating virtual environment at: .venv
Resolved 6 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ typing-extensions==4.10.0
");
// The main requires-python and the default group's requires-python should be consulted
// (This should trigger a version bump)
uv_snapshot!(context.filters(), context.run()
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 6 packages in [TIME]
Prepared 1 package in [TIME]
Installed 2 packages in [TIME]
+ sniffio==1.3.1
+ typing-extensions==4.10.0
");
// The main requires-python and "dev" and "bar" requires-python should be consulted
// (This should trigger a version bump)
uv_snapshot!(context.filters(), context.run()
.arg("--group").arg("bar")
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.13.[X] interpreter at: [PYTHON-3.13]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 6 packages in [TIME]
Prepared 1 package in [TIME]
Installed 3 packages in [TIME]
+ iniconfig==2.0.0
+ sniffio==1.3.1
+ typing-extensions==4.10.0
");
// Going back to just "dev" we shouldn't churn the venv needlessly
uv_snapshot!(context.filters(), context.run()
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Audited 2 packages in [TIME]
");
// Explicitly requesting an in-range python can downgrade
uv_snapshot!(context.filters(), context.run()
.arg("-p").arg("3.12")
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 6 packages in [TIME]
Installed 2 packages in [TIME]
+ sniffio==1.3.1
+ typing-extensions==4.10.0
");
// Explicitly requesting an out-of-range python fails
uv_snapshot!(context.filters(), context.run()
.arg("-p").arg("3.11")
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
error: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `tool.uv.dependency-groups.dev.requires-python`).
");
// Enabling foo we can't find an interpreter
uv_snapshot!(context.filters(), context.run()
.arg("--group").arg("foo")
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: No interpreter found for Python >=3.14 in [PYTHON SOURCES]
");
Ok(())
}
#[test]
fn run_groups_include_requires_python() -> Result<()> {
let context = TestContext::new_with_versions(&["3.11", "3.12", "3.13"]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["typing-extensions"]
[dependency-groups]
foo = ["anyio"]
bar = ["iniconfig"]
baz = ["iniconfig"]
dev = ["sniffio", {include-group = "foo"}, {include-group = "baz"}]
[tool.uv.dependency-groups]
foo = {requires-python="<3.13"}
bar = {requires-python=">=3.13"}
baz = {requires-python=">=3.12"}
"#,
)?;
context.lock().assert().success();
// With --no-default-groups only the main requires-python should be consulted
uv_snapshot!(context.filters(), context.run()
.arg("--no-default-groups")
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
Creating virtual environment at: .venv
Resolved 6 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ typing-extensions==4.10.0
");
// The main requires-python and the default group's requires-python should be consulted
// (This should trigger a version bump)
uv_snapshot!(context.filters(), context.run()
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 6 packages in [TIME]
Prepared 4 packages in [TIME]
Installed 5 packages in [TIME]
+ anyio==4.3.0
+ idna==3.6
+ iniconfig==2.0.0
+ sniffio==1.3.1
+ typing-extensions==4.10.0
");
// The main requires-python and "dev" and "bar" requires-python should be consulted
// (This should trigger a conflict)
uv_snapshot!(context.filters(), context.run()
.arg("--group").arg("bar")
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Found conflicting Python requirements:
- project: >=3.11
- project:bar: >=3.13
- project:dev: >=3.12, <3.13
");
// Explicitly requesting an out-of-range python fails
uv_snapshot!(context.filters(), context.run()
.arg("-p").arg("3.13")
.arg("python").arg("-c").arg("import typing_extensions"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.13.[X] interpreter at: [PYTHON-3.13]
error: The requested interpreter resolved to Python 3.13.[X], which is incompatible with the project's Python requirement: `==3.12.*` (from `tool.uv.dependency-groups.dev.requires-python`).
");
Ok(())
}
/// Test that a signal n makes the process exit with code 128+n.
#[cfg(unix)]
#[test]

View file

@ -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`
"
);

View file

@ -346,7 +346,296 @@ fn mixed_requires_python() -> Result<()> {
----- stderr -----
Using CPython 3.9.[X] interpreter at: [PYTHON-3.9]
error: The requested interpreter resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12`. However, a workspace member (`bird-feeder`) supports Python >=3.9. To install the workspace member on its own, navigate to `packages/bird-feeder`, then run `uv venv --python 3.9.[X]` followed by `uv pip install -e .`.
error: The requested interpreter resolved to Python 3.9.[X], which is incompatible with the project's Python requirement: `>=3.12` (from workspace member `albatross`'s `project.requires-python`).
");
Ok(())
}
/// Ensure that group requires-python solves an actual problem
#[test]
#[cfg(not(windows))]
fn group_requires_python_useful_defaults() -> Result<()> {
let context = TestContext::new_with_versions(&["3.8", "3.9"]);
// Require 3.8 for our project, but have a dev-dependency on a version of sphinx that needs 3.9
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "pharaohs-tomp"
version = "0.1.0"
requires-python = ">=3.8"
dependencies = ["anyio"]
[dependency-groups]
dev = ["sphinx>=7.2.6"]
"#,
)?;
let src = context.temp_dir.child("src").child("albatross");
src.create_dir_all()?;
let init = src.child("__init__.py");
init.touch()?;
// Running `uv sync --no-dev` should ideally succeed, locking for Python 3.8.
// ...but once we pick the 3.8 interpreter the lock freaks out because it sees
// that the dependency-group containing sphinx will never successfully install,
// even though it's not enabled!
uv_snapshot!(context.filters(), context.sync()
.arg("--no-dev"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Using CPython 3.8.[X] interpreter at: [PYTHON-3.8]
Creating virtual environment at: .venv
× No solution found when resolving dependencies for split (python_full_version == '3.8.*'):
Because the requested Python version (>=3.8) does not satisfy Python>=3.9 and sphinx==7.2.6 depends on Python>=3.9, we can conclude that sphinx==7.2.6 cannot be used.
And because only sphinx<=7.2.6 is available, we can conclude that sphinx>=7.2.6 cannot be used.
And because pharaohs-tomp:dev depends on sphinx>=7.2.6 and your project requires pharaohs-tomp:dev, we can conclude that your project's requirements are unsatisfiable.
hint: The `requires-python` value (>=3.8) includes Python versions that are not supported by your dependencies (e.g., sphinx==7.2.6 only supports >=3.9). Consider using a more restrictive `requires-python` value (like >=3.9).
");
// Running `uv sync` should always fail, as now sphinx is involved
uv_snapshot!(context.filters(), context.sync(), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies for split (python_full_version == '3.8.*'):
Because the requested Python version (>=3.8) does not satisfy Python>=3.9 and sphinx==7.2.6 depends on Python>=3.9, we can conclude that sphinx==7.2.6 cannot be used.
And because only sphinx<=7.2.6 is available, we can conclude that sphinx>=7.2.6 cannot be used.
And because pharaohs-tomp:dev depends on sphinx>=7.2.6 and your project requires pharaohs-tomp:dev, we can conclude that your project's requirements are unsatisfiable.
hint: The `requires-python` value (>=3.8) includes Python versions that are not supported by your dependencies (e.g., sphinx==7.2.6 only supports >=3.9). Consider using a more restrictive `requires-python` value (like >=3.9).
");
// Adding group requires python should fix it
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "pharaohs-tomp"
version = "0.1.0"
requires-python = ">=3.8"
dependencies = ["anyio"]
[dependency-groups]
dev = ["sphinx>=7.2.6"]
[tool.uv.dependency-groups]
dev = {requires-python = ">=3.9"}
"#,
)?;
// Running `uv sync --no-dev` should succeed, still using the Python 3.8.
uv_snapshot!(context.filters(), context.sync()
.arg("--no-dev"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 29 packages in [TIME]
Prepared 5 packages in [TIME]
Installed 5 packages in [TIME]
+ anyio==4.3.0
+ exceptiongroup==1.2.0
+ idna==3.6
+ sniffio==1.3.1
+ typing-extensions==4.10.0
");
// Running `uv sync` should succeed, bumping to Python 3.9 as sphinx is now involved.
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.9.[X] interpreter at: [PYTHON-3.9]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 29 packages in [TIME]
Prepared 22 packages in [TIME]
Installed 27 packages in [TIME]
+ alabaster==0.7.16
+ anyio==4.3.0
+ babel==2.14.0
+ certifi==2024.2.2
+ charset-normalizer==3.3.2
+ docutils==0.20.1
+ exceptiongroup==1.2.0
+ idna==3.6
+ imagesize==1.4.1
+ importlib-metadata==7.1.0
+ jinja2==3.1.3
+ markupsafe==2.1.5
+ packaging==24.0
+ pygments==2.17.2
+ requests==2.31.0
+ sniffio==1.3.1
+ snowballstemmer==2.2.0
+ sphinx==7.2.6
+ sphinxcontrib-applehelp==1.0.8
+ sphinxcontrib-devhelp==1.0.6
+ sphinxcontrib-htmlhelp==2.0.5
+ sphinxcontrib-jsmath==1.0.1
+ sphinxcontrib-qthelp==1.0.7
+ sphinxcontrib-serializinghtml==1.1.10
+ typing-extensions==4.10.0
+ urllib3==2.2.1
+ zipp==3.18.1
");
Ok(())
}
/// Ensure that group requires-python solves an actual problem
#[test]
#[cfg(not(windows))]
fn group_requires_python_useful_non_defaults() -> Result<()> {
let context = TestContext::new_with_versions(&["3.8", "3.9"]);
// Require 3.8 for our project, but have a dev-dependency on a version of sphinx that needs 3.9
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "pharaohs-tomp"
version = "0.1.0"
requires-python = ">=3.8"
dependencies = ["anyio"]
[dependency-groups]
mygroup = ["sphinx>=7.2.6"]
"#,
)?;
let src = context.temp_dir.child("src").child("albatross");
src.create_dir_all()?;
let init = src.child("__init__.py");
init.touch()?;
// Running `uv sync` should ideally succeed, locking for Python 3.8.
// ...but once we pick the 3.8 interpreter the lock freaks out because it sees
// that the dependency-group containing sphinx will never successfully install,
// even though it's not enabled, or even a default!
uv_snapshot!(context.filters(), context.sync(), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Using CPython 3.8.[X] interpreter at: [PYTHON-3.8]
Creating virtual environment at: .venv
× No solution found when resolving dependencies for split (python_full_version == '3.8.*'):
Because the requested Python version (>=3.8) does not satisfy Python>=3.9 and sphinx==7.2.6 depends on Python>=3.9, we can conclude that sphinx==7.2.6 cannot be used.
And because only sphinx<=7.2.6 is available, we can conclude that sphinx>=7.2.6 cannot be used.
And because pharaohs-tomp:mygroup depends on sphinx>=7.2.6 and your project requires pharaohs-tomp:mygroup, we can conclude that your project's requirements are unsatisfiable.
hint: The `requires-python` value (>=3.8) includes Python versions that are not supported by your dependencies (e.g., sphinx==7.2.6 only supports >=3.9). Consider using a more restrictive `requires-python` value (like >=3.9).
");
// Running `uv sync --group mygroup` should definitely fail, as now sphinx is involved
uv_snapshot!(context.filters(), context.sync()
.arg("--group").arg("mygroup"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× No solution found when resolving dependencies for split (python_full_version == '3.8.*'):
Because the requested Python version (>=3.8) does not satisfy Python>=3.9 and sphinx==7.2.6 depends on Python>=3.9, we can conclude that sphinx==7.2.6 cannot be used.
And because only sphinx<=7.2.6 is available, we can conclude that sphinx>=7.2.6 cannot be used.
And because pharaohs-tomp:mygroup depends on sphinx>=7.2.6 and your project requires pharaohs-tomp:mygroup, we can conclude that your project's requirements are unsatisfiable.
hint: The `requires-python` value (>=3.8) includes Python versions that are not supported by your dependencies (e.g., sphinx==7.2.6 only supports >=3.9). Consider using a more restrictive `requires-python` value (like >=3.9).
");
// Adding group requires python should fix it
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "pharaohs-tomp"
version = "0.1.0"
requires-python = ">=3.8"
dependencies = ["anyio"]
[dependency-groups]
mygroup = ["sphinx>=7.2.6"]
[tool.uv.dependency-groups]
mygroup = {requires-python = ">=3.9"}
"#,
)?;
// Running `uv sync` should succeed, locking for the previous picked Python 3.8.
uv_snapshot!(context.filters(), context.sync(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 29 packages in [TIME]
Prepared 5 packages in [TIME]
Installed 5 packages in [TIME]
+ anyio==4.3.0
+ exceptiongroup==1.2.0
+ idna==3.6
+ sniffio==1.3.1
+ typing-extensions==4.10.0
");
// Running `uv sync --group mygroup` should pass, bumping the interpreter to 3.9,
// as the group requires-python saves us
uv_snapshot!(context.filters(), context.sync()
.arg("--group").arg("mygroup"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.9.[X] interpreter at: [PYTHON-3.9]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 29 packages in [TIME]
Prepared 22 packages in [TIME]
Installed 27 packages in [TIME]
+ alabaster==0.7.16
+ anyio==4.3.0
+ babel==2.14.0
+ certifi==2024.2.2
+ charset-normalizer==3.3.2
+ docutils==0.20.1
+ exceptiongroup==1.2.0
+ idna==3.6
+ imagesize==1.4.1
+ importlib-metadata==7.1.0
+ jinja2==3.1.3
+ markupsafe==2.1.5
+ packaging==24.0
+ pygments==2.17.2
+ requests==2.31.0
+ sniffio==1.3.1
+ snowballstemmer==2.2.0
+ sphinx==7.2.6
+ sphinxcontrib-applehelp==1.0.8
+ sphinxcontrib-devhelp==1.0.6
+ sphinxcontrib-htmlhelp==2.0.5
+ sphinxcontrib-jsmath==1.0.1
+ sphinxcontrib-qthelp==1.0.7
+ sphinxcontrib-serializinghtml==1.1.10
+ typing-extensions==4.10.0
+ urllib3==2.2.1
+ zipp==3.18.1
");
Ok(())
@ -4080,17 +4369,17 @@ fn sync_custom_environment_path() -> Result<()> {
// But if it's just an incompatible virtual environment...
fs_err::remove_dir_all(context.temp_dir.join("foo"))?;
uv_snapshot!(context.filters(), context.venv().arg("foo").arg("--python").arg("3.11"), @r###"
uv_snapshot!(context.filters(), context.venv().arg("foo").arg("--python").arg("3.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`)
Creating virtual environment at: foo
Activate with: source foo/[BIN]/activate
"###);
");
// Even with some extraneous content...
fs_err::write(context.temp_dir.join("foo").join("file"), b"")?;
@ -5817,17 +6106,17 @@ fn sync_invalid_environment() -> Result<()> {
// But if it's just an incompatible virtual environment...
fs_err::remove_dir_all(context.temp_dir.join(".venv"))?;
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r###"
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`)
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"###);
");
// Even with some extraneous content...
fs_err::write(context.temp_dir.join(".venv").join("file"), b"")?;
@ -5884,17 +6173,17 @@ fn sync_invalid_environment() -> Result<()> {
// But if it's not a virtual environment...
fs_err::remove_dir_all(context.temp_dir.join(".venv"))?;
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r###"
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`)
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"###);
");
// Which we detect by the presence of a `pyvenv.cfg` file
fs_err::remove_file(context.temp_dir.join(".venv").join("pyvenv.cfg"))?;
@ -6004,15 +6293,15 @@ fn sync_python_version() -> Result<()> {
"###);
// Unless explicitly requested...
uv_snapshot!(context.filters(), context.sync().arg("--python").arg("3.10"), @r###"
uv_snapshot!(context.filters(), context.sync().arg("--python").arg("3.10"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.10.[X] interpreter at: [PYTHON-3.10]
error: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`
"###);
error: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` (from `project.requires-python`)
");
// But a pin should take precedence
uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###"
@ -6051,15 +6340,16 @@ fn sync_python_version() -> Result<()> {
"###);
// We should warn on subsequent uses, but respect the pinned version?
uv_snapshot!(context.filters(), context.sync(), @r###"
uv_snapshot!(context.filters(), context.sync(), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.10.[X] interpreter at: [PYTHON-3.10]
error: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`. Use `uv python pin` to update the `.python-version` file to a compatible version.
"###);
error: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` (from `project.requires-python`)
Use `uv python pin` to update the `.python-version` file to a compatible version
");
// Unless the pin file is outside the project, in which case we should just ignore it entirely
let child_dir = context.temp_dir.child("child");
@ -8935,52 +9225,52 @@ fn transitive_group_conflicts_cycle() -> Result<()> {
uv_snapshot!(context.filters(), context.sync(), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `example @ file://[TEMP_DIR]/`
Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
error: Project `example` has malformed dependency groups
Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
");
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("dev"), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `example @ file://[TEMP_DIR]/`
Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
error: Project `example` has malformed dependency groups
Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
");
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("dev").arg("--group").arg("test"), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `example @ file://[TEMP_DIR]/`
Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
error: Project `example` has malformed dependency groups
Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
");
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("test").arg("--group").arg("magic"), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `example @ file://[TEMP_DIR]/`
Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
error: Project `example` has malformed dependency groups
Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
");
uv_snapshot!(context.filters(), context.sync().arg("--group").arg("dev").arg("--group").arg("magic"), @r"
success: false
exit_code: 1
exit_code: 2
----- stdout -----
----- stderr -----
× Failed to build `example @ file://[TEMP_DIR]/`
Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
error: Project `example` has malformed dependency groups
Caused by: Detected a cycle in `dependency-groups`: `dev` -> `test` -> `dev`
");
Ok(())

View file

@ -475,17 +475,195 @@ fn create_venv_respects_pyproject_requires_python() -> Result<()> {
context.venv.assert(predicates::path::is_dir());
// We warn if we receive an incompatible version
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r###"
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `project.requires-python`)
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"###
"
);
Ok(())
}
#[test]
fn create_venv_respects_group_requires_python() -> Result<()> {
let context = TestContext::new_with_versions(&["3.9", "3.10", "3.11", "3.12"]);
// Without a Python requirement, we use the first on the PATH
uv_snapshot!(context.filters(), context.venv(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.9.[X] interpreter at: [PYTHON-3.9]
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
// With `requires-python = ">=3.10"` on the default group, we pick 3.10
// However non-default groups should not be consulted!
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
dependencies = []
[dependency-groups]
dev = ["sortedcontainers"]
other = ["sniffio"]
[tool.uv.dependency-groups]
dev = {requires-python = ">=3.10"}
other = {requires-python = ">=3.12"}
"#
})?;
uv_snapshot!(context.filters(), context.venv(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.10.[X] interpreter at: [PYTHON-3.10]
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
// When the top-level requires-python and default group requires-python
// both apply, their intersection is used. However non-default groups
// should not be consulted! (here the top-level wins)
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.11"
dependencies = []
[dependency-groups]
dev = ["sortedcontainers"]
other = ["sniffio"]
[tool.uv.dependency-groups]
dev = {requires-python = ">=3.10"}
other = {requires-python = ">=3.12"}
"#
})?;
uv_snapshot!(context.filters(), context.venv(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
// When the top-level requires-python and default group requires-python
// both apply, their intersection is used. However non-default groups
// should not be consulted! (here the group wins)
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = []
[dependency-groups]
dev = ["sortedcontainers"]
other = ["sniffio"]
[tool.uv.dependency-groups]
dev = {requires-python = ">=3.11"}
other = {requires-python = ">=3.12"}
"#
})?;
uv_snapshot!(context.filters(), context.venv(), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
// We warn if we receive an incompatible version
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
dependencies = []
[dependency-groups]
dev = ["sortedcontainers"]
[tool.uv.dependency-groups]
dev = {requires-python = ">=3.12"}
"#
})?;
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` (from `tool.uv.dependency-groups.dev.requires-python`).
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"
);
// We error if there's no compatible version
// non-default groups are not consulted here!
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = "<3.12"
dependencies = []
[dependency-groups]
dev = ["sortedcontainers"]
other = ["sniffio"]
[tool.uv.dependency-groups]
dev = {requires-python = ">=3.12"}
other = {requires-python = ">=3.11"}
"#
})?;
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
× Found conflicting Python requirements:
- foo: <3.12
- foo:dev: >=3.12
"
);
Ok(())