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

1
Cargo.lock generated
View file

@ -6014,6 +6014,7 @@ dependencies = [
"tracing",
"uv-build-backend",
"uv-cache-key",
"uv-configuration",
"uv-distribution-types",
"uv-fs",
"uv-git-types",

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

View file

@ -127,6 +127,31 @@ default-groups = ["docs"]
---
### [`dependency-groups`](#dependency-groups) {: #dependency-groups }
Additional settings for `dependency-groups`.
Currently this can only be used to add `requires-python` constraints
to dependency groups (typically to inform uv that your dev tooling
has a higher python requirement than your actual project).
This cannot be used to define dependency groups, use the top-level
`[dependency-groups]` table for that.
**Default value**: `[]`
**Type**: `dict`
**Example usage**:
```toml title="pyproject.toml"
[tool.uv.dependency-groups]
my-group = {requires-python = ">=3.12"}
```
---
### [`dev-dependencies`](#dev-dependencies) {: #dev-dependencies }
The project's development dependencies.

29
uv.schema.json generated
View file

@ -151,6 +151,17 @@
}
]
},
"dependency-groups": {
"description": "Additional settings for `dependency-groups`.\n\nCurrently this can only be used to add `requires-python` constraints to dependency groups (typically to inform uv that your dev tooling has a higher python requirement than your actual project).\n\nThis cannot be used to define dependency groups, use the top-level `[dependency-groups]` table for that.",
"anyOf": [
{
"$ref": "#/definitions/ToolUvDependencyGroups"
},
{
"type": "null"
}
]
},
"dependency-metadata": {
"description": "Pre-defined static metadata for dependencies of the project (direct or transitive). When provided, enables the resolver to use the specified metadata instead of querying the registry or building the relevant package from source.\n\nMetadata should be provided in adherence with the [Metadata 2.3](https://packaging.python.org/en/latest/specifications/core-metadata/) standard, though only the following fields are respected:\n\n- `name`: The name of the package. - (Optional) `version`: The version of the package. If omitted, the metadata will be applied to all versions of the package. - (Optional) `requires-dist`: The dependencies of the package (e.g., `werkzeug>=0.14`). - (Optional) `requires-python`: The Python version required by the package (e.g., `>=3.10`). - (Optional) `provides-extras`: The extras provided by the package.",
"type": [
@ -824,6 +835,18 @@
}
]
},
"DependencyGroupSettings": {
"type": "object",
"properties": {
"requires-python": {
"description": "Version of python to require when installing this group",
"type": [
"string",
"null"
]
}
}
},
"ExcludeNewer": {
"description": "Exclude distributions uploaded after the given timestamp.\n\nAccepts both RFC 3339 timestamps (e.g., `2006-12-02T02:07:43Z`) and local dates in the same format (e.g., `2006-12-02`).",
"type": "string",
@ -2344,6 +2367,12 @@
}
]
},
"ToolUvDependencyGroups": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/DependencyGroupSettings"
}
},
"ToolUvSources": {
"type": "object",
"additionalProperties": {