diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index f1d94f44cfc6a..79f2ba47c3f41 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -14,7 +14,7 @@ use uv_configuration::{ TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, }; use uv_distribution_types::{FlatIndexLocation, IndexUrl}; -use uv_normalize::{ExtraName, PackageName}; +use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep508::Requirement; use uv_pypi_types::VerbatimParsedUrl; use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; @@ -2877,7 +2877,7 @@ pub struct AddArgs { pub requirements: Vec, /// Add the requirements as development dependencies. - #[arg(long, conflicts_with("optional"))] + #[arg(long, conflicts_with("optional"), conflicts_with("group"))] pub dev: bool, /// Add the requirements to the specified optional dependency group. @@ -2887,9 +2887,15 @@ pub struct AddArgs { /// /// To enable an optional dependency group for this requirement instead, see /// `--extra`. - #[arg(long, conflicts_with("dev"))] + #[arg(long, conflicts_with("dev"), conflicts_with("group"))] pub optional: Option, + /// Add the requirements to the specified local dependency group. + /// + /// These requirements will not be included in the published metadata for the project. + #[arg(long, conflicts_with("dev"), conflicts_with("optional"))] + pub group: Option, + /// Add the requirements as editable. #[arg(long, overrides_with = "no_editable")] pub editable: bool, @@ -2995,13 +3001,17 @@ pub struct RemoveArgs { pub packages: Vec, /// Remove the packages from the development dependencies. - #[arg(long, conflicts_with("optional"))] + #[arg(long, conflicts_with("optional"), conflicts_with("group"))] pub dev: bool, /// Remove the packages from the specified optional dependency group. - #[arg(long, conflicts_with("dev"))] + #[arg(long, conflicts_with("dev"), conflicts_with("group"))] pub optional: Option, + /// Remove the packages from the specified local dependency group. + #[arg(long, conflicts_with("dev"), conflicts_with("optional"))] + pub group: Option, + /// Avoid syncing the virtual environment after re-locking the project. #[arg(long, env = "UV_NO_SYNC", value_parser = clap::builder::BoolishValueParser::new(), conflicts_with = "frozen")] pub no_sync: bool, diff --git a/crates/uv-configuration/src/dev.rs b/crates/uv-configuration/src/dev.rs index ab11c55fadcad..ba4be17210c59 100644 --- a/crates/uv-configuration/src/dev.rs +++ b/crates/uv-configuration/src/dev.rs @@ -1,5 +1,5 @@ use either::Either; -use uv_normalize::GroupName; +use uv_normalize::{GroupName, DEV_DEPENDENCIES}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum DevMode { @@ -27,17 +27,17 @@ impl DevMode { } } -#[derive(Debug, Copy, Clone)] -pub enum DevSpecification<'group> { +#[derive(Debug, Clone)] +pub enum DevSpecification { /// Include dev dependencies from the specified group. - Include(&'group [GroupName]), + Include(Vec), /// Do not include dev dependencies. Exclude, - /// Include dev dependencies from the specified group, and exclude all non-dev dependencies. - Only(&'group [GroupName]), + /// Include dev dependencies from the specified groups, and exclude all non-dev dependencies. + Only(Vec), } -impl<'group> DevSpecification<'group> { +impl DevSpecification { /// Returns an [`Iterator`] over the group names to include. pub fn iter(&self) -> impl Iterator { match self { @@ -51,3 +51,13 @@ impl<'group> DevSpecification<'group> { matches!(self, Self::Exclude | Self::Include(_)) } } + +impl From for DevSpecification { + fn from(mode: DevMode) -> Self { + match mode { + DevMode::Include => Self::Include(vec![DEV_DEPENDENCIES.clone()]), + DevMode::Exclude => Self::Exclude, + DevMode::Only => Self::Only(vec![DEV_DEPENDENCIES.clone()]), + } + } +} diff --git a/crates/uv-distribution/src/metadata/mod.rs b/crates/uv-distribution/src/metadata/mod.rs index 6f18baa0e740d..7106746d8e987 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -6,7 +6,8 @@ use thiserror::Error; use uv_configuration::SourceStrategy; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; -use uv_pypi_types::{HashDigest, ResolutionMetadata}; +use uv_pep508::Pep508Error; +use uv_pypi_types::{HashDigest, ResolutionMetadata, VerbatimParsedUrl}; use uv_workspace::WorkspaceError; pub use crate::metadata::lowering::LoweredRequirement; @@ -20,10 +21,16 @@ mod requires_dist; pub enum MetadataError { #[error(transparent)] Workspace(#[from] WorkspaceError), - #[error("Failed to parse entry for: `{0}`")] - LoweringError(PackageName, #[source] LoweringError), - #[error(transparent)] - Lower(#[from] LoweringError), + #[error("Failed to parse entry: `{0}`")] + LoweringError(PackageName, #[source] Box), + #[error("Failed to parse entry in `{0}`: `{1}`")] + GroupLoweringError(GroupName, PackageName, #[source] Box), + #[error("Failed to parse entry in `{0}`: `{1}`")] + GroupParseError( + GroupName, + String, + #[source] Box>, + ), } #[derive(Debug, Clone)] diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index 7c3564b5419b2..bd19e900b8f85 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -3,9 +3,11 @@ use crate::Metadata; use std::collections::BTreeMap; use std::path::Path; +use std::str::FromStr; use uv_configuration::SourceStrategy; use uv_normalize::{ExtraName, GroupName, PackageName, DEV_DEPENDENCIES}; -use uv_workspace::pyproject::ToolUvSources; +use uv_pypi_types::VerbatimParsedUrl; +use uv_workspace::pyproject::{Sources, ToolUvSources}; use uv_workspace::{DiscoveryOptions, ProjectWorkspace}; #[derive(Debug, Clone)] @@ -72,45 +74,68 @@ impl RequiresDist { }; let dev_dependencies = { + // 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()) - .into_iter() + .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() - .cloned(); - let dev_dependencies = match source_strategy { - SourceStrategy::Enabled => dev_dependencies - .flat_map(|requirement| { - let requirement_name = requirement.name.clone(); - LoweredRequirement::from_requirement( - requirement, - &metadata.name, - project_workspace.project_root(), + .map(|(name, requirements)| { + ( + name.clone(), + requirements + .iter() + .map(|requirement| { + match uv_pep508::Requirement::::from_str( + requirement, + ) { + Ok(requirement) => Ok(requirement), + Err(err) => Err(MetadataError::GroupParseError( + name.clone(), + requirement.clone(), + Box::new(err), + )), + } + }) + .collect::, _>>(), + ) + }) + .chain( + // Only add the `dev` group if `dev-dependencies` is defined + dev_dependencies + .into_iter() + .map(|requirements| (DEV_DEPENDENCIES.clone(), Ok(requirements.clone()))), + ) + .map(|(name, requirements)| { + // Apply sources to the requirements + match requirements { + Ok(requirements) => match apply_source_strategy( + source_strategy, + &metadata, + project_workspace, sources, - project_workspace.workspace(), - ) - .map(move |requirement| match requirement { - Ok(requirement) => Ok(requirement.into_inner()), - Err(err) => { - Err(MetadataError::LoweringError(requirement_name.clone(), err)) - } - }) - }) - .collect::, _>>()?, - SourceStrategy::Disabled => dev_dependencies - .into_iter() - .map(uv_pypi_types::Requirement::from) - .collect(), - }; - if dev_dependencies.is_empty() { - BTreeMap::default() - } else { - BTreeMap::from([(DEV_DEPENDENCIES.clone(), dev_dependencies)]) - } + &name, + requirements, + ) { + Ok(requirements) => Ok((name, requirements)), + Err(err) => Err(err), + }, + Err(err) => Err(err), + } + }) + .collect::, _>>()?; + + dependency_groups.into_iter().collect::>() }; let requires_dist = metadata.requires_dist.into_iter(); @@ -127,9 +152,10 @@ impl RequiresDist { ) .map(move |requirement| match requirement { Ok(requirement) => Ok(requirement.into_inner()), - Err(err) => { - Err(MetadataError::LoweringError(requirement_name.clone(), err)) - } + Err(err) => Err(MetadataError::LoweringError( + requirement_name.clone(), + Box::new(err), + )), }) }) .collect::, _>>()?, @@ -159,6 +185,43 @@ impl From for RequiresDist { } } +fn apply_source_strategy( + source_strategy: SourceStrategy, + metadata: &uv_pypi_types::RequiresDist, + project_workspace: &ProjectWorkspace, + sources: &BTreeMap, + group_name: &GroupName, + requirements: Vec>, +) -> Result, MetadataError> { + match source_strategy { + SourceStrategy::Enabled => requirements + .into_iter() + .flat_map(|requirement| { + let requirement_name = requirement.name.clone(); + LoweredRequirement::from_requirement( + requirement, + &metadata.name, + project_workspace.project_root(), + sources, + project_workspace.workspace(), + ) + .map(move |requirement| match requirement { + Ok(requirement) => Ok(requirement.into_inner()), + Err(err) => Err(MetadataError::GroupLoweringError( + group_name.clone(), + requirement_name.clone(), + Box::new(err), + )), + }) + }) + .collect::, _>>(), + SourceStrategy::Disabled => Ok(requirements + .into_iter() + .map(uv_pypi_types::Requirement::from) + .collect()), + } +} + #[cfg(test)] mod test { use std::path::Path; diff --git a/crates/uv-normalize/src/group_name.rs b/crates/uv-normalize/src/group_name.rs index d41d3791cb459..72aa898ec7295 100644 --- a/crates/uv-normalize/src/group_name.rs +++ b/crates/uv-normalize/src/group_name.rs @@ -3,7 +3,7 @@ use std::fmt::{Display, Formatter}; use std::str::FromStr; use std::sync::LazyLock; -use serde::{Deserialize, Deserializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::{validate_and_normalize_owned, validate_and_normalize_ref, InvalidNameError}; @@ -41,6 +41,15 @@ impl<'de> Deserialize<'de> for GroupName { } } +impl Serialize for GroupName { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.0.serialize(serializer) + } +} + impl Display for GroupName { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { self.0.fmt(f) diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 1c4de1cd09d0e..e0fb6f415c24d 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -551,7 +551,7 @@ impl Lock { marker_env: &ResolverMarkerEnvironment, tags: &Tags, extras: &ExtrasSpecification, - dev: DevSpecification<'_>, + dev: &DevSpecification, build_options: &BuildOptions, install_options: &InstallOptions, ) -> Result { diff --git a/crates/uv-resolver/src/lock/requirements_txt.rs b/crates/uv-resolver/src/lock/requirements_txt.rs index 1393a459f5bdc..b6f30385e3c21 100644 --- a/crates/uv-resolver/src/lock/requirements_txt.rs +++ b/crates/uv-resolver/src/lock/requirements_txt.rs @@ -43,7 +43,7 @@ impl<'lock> RequirementsTxtExport<'lock> { lock: &'lock Lock, root_name: &PackageName, extras: &ExtrasSpecification, - dev: DevSpecification<'_>, + dev: &DevSpecification, editable: EditableMode, hashes: bool, install_options: &'lock InstallOptions, diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 430775d414566..909f5bd54d5c0 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -19,7 +19,7 @@ use url::Url; use uv_fs::{relative_to, PortablePathBuf}; use uv_git::GitReference; use uv_macros::OptionsMetadata; -use uv_normalize::{ExtraName, PackageName}; +use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::{Version, VersionSpecifiers}; use uv_pep508::MarkerTree; use uv_pypi_types::{RequirementSource, SupportedEnvironments, VerbatimParsedUrl}; @@ -44,7 +44,7 @@ pub struct PyProjectToml { /// Tool-specific metadata. pub tool: Option, /// Non-project dependency groups, as defined in PEP 735. - pub dependency_groups: Option>>, + pub dependency_groups: Option>>, /// The raw unserialized document. #[serde(skip)] pub raw: String, @@ -1056,7 +1056,7 @@ pub enum DependencyType { /// A dependency in `project.optional-dependencies.{0}`. Optional(ExtraName), /// A dependency in `dependency-groups.{0}`. - Group(ExtraName), + Group(GroupName), } /// diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 496df0310062b..47d50350e1993 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -5,6 +5,7 @@ use std::{fmt, mem}; use thiserror::Error; use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value}; use uv_fs::PortablePath; +use uv_normalize::GroupName; use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers}; use uv_pep508::{ExtraName, MarkerTree, PackageName, Requirement, VersionOrUrl}; @@ -102,9 +103,11 @@ impl PyProjectTomlMut { Ok(()) } - /// Retrieves a mutable reference to the root [`Table`] of the TOML document, creating the - /// `project` table if necessary. - fn doc(&mut self) -> Result<&mut Table, Error> { + /// Retrieves a mutable reference to the `project` [`Table`] of the TOML document, creating the + /// table if necessary. + /// + /// For a script, this returns the root table. + fn project(&mut self) -> Result<&mut Table, Error> { let doc = match self.target { DependencyTarget::Script => self.doc.as_table_mut(), DependencyTarget::PyProjectToml => self @@ -119,7 +122,9 @@ impl PyProjectTomlMut { /// Retrieves an optional mutable reference to the `project` [`Table`], returning `None` if it /// doesn't exist. - fn doc_mut(&mut self) -> Result, Error> { + /// + /// For a script, this returns the root table. + fn project_mut(&mut self) -> Result, Error> { let doc = match self.target { DependencyTarget::Script => Some(self.doc.as_table_mut()), DependencyTarget::PyProjectToml => self @@ -130,6 +135,7 @@ impl PyProjectTomlMut { }; Ok(doc) } + /// Adds a dependency to `project.dependencies`. /// /// Returns `true` if the dependency was added, `false` if it was updated. @@ -140,7 +146,7 @@ impl PyProjectTomlMut { ) -> Result { // Get or create `project.dependencies`. let dependencies = self - .doc()? + .project()? .entry("dependencies") .or_insert(Item::Value(Value::Array(Array::new()))) .as_array_mut() @@ -201,7 +207,7 @@ impl PyProjectTomlMut { ) -> Result { // Get or create `project.optional-dependencies`. let optional_dependencies = self - .doc()? + .project()? .entry("optional-dependencies") .or_insert(Item::Table(Table::new())) .as_table_like_mut() @@ -225,6 +231,41 @@ impl PyProjectTomlMut { Ok(added) } + /// Adds a dependency to `dependency-groups`. + /// + /// Returns `true` if the dependency was added, `false` if it was updated. + pub fn add_dependency_group_requirement( + &mut self, + group: &GroupName, + req: &Requirement, + source: Option<&Source>, + ) -> Result { + // Get or create `dependency-groups`. + let dependency_groups = self + .doc + .entry("dependency-groups") + .or_insert(Item::Table(Table::new())) + .as_table_like_mut() + .ok_or(Error::MalformedDependencies)?; + + let group = dependency_groups + .entry(group.as_ref()) + .or_insert(Item::Value(Value::Array(Array::new()))) + .as_array_mut() + .ok_or(Error::MalformedDependencies)?; + + let name = req.name.clone(); + let added = add_dependency(req, group, source.is_some())?; + + dependency_groups.fmt(); + + if let Some(source) = source { + self.add_source(&name, source)?; + } + + Ok(added) + } + /// Set the minimum version for an existing dependency in `project.dependencies`. pub fn set_dependency_minimum_version( &mut self, @@ -233,7 +274,7 @@ impl PyProjectTomlMut { ) -> Result<(), Error> { // Get or create `project.dependencies`. let dependencies = self - .doc()? + .project()? .entry("dependencies") .or_insert(Item::Value(Value::Array(Array::new()))) .as_array_mut() @@ -302,7 +343,7 @@ impl PyProjectTomlMut { ) -> Result<(), Error> { // Get or create `project.optional-dependencies`. let optional_dependencies = self - .doc()? + .project()? .entry("optional-dependencies") .or_insert(Item::Table(Table::new())) .as_table_like_mut() @@ -330,6 +371,43 @@ impl PyProjectTomlMut { Ok(()) } + /// Set the minimum version for an existing dependency in `dependency-groups`. + pub fn set_dependency_group_requirement_minimum_version( + &mut self, + group: &GroupName, + index: usize, + version: Version, + ) -> Result<(), Error> { + // Get or create `dependency-groups`. + let dependency_groups = self + .doc + .entry("dependency-groups") + .or_insert(Item::Table(Table::new())) + .as_table_like_mut() + .ok_or(Error::MalformedDependencies)?; + + let group = dependency_groups + .entry(group.as_ref()) + .or_insert(Item::Value(Value::Array(Array::new()))) + .as_array_mut() + .ok_or(Error::MalformedDependencies)?; + + let Some(req) = group.get(index) else { + return Err(Error::MissingDependency(index)); + }; + + let mut req = req + .as_str() + .and_then(try_parse_requirement) + .ok_or(Error::MalformedDependencies)?; + req.version_or_url = Some(VersionOrUrl::VersionSpecifier(VersionSpecifiers::from( + VersionSpecifier::greater_than_equal_version(version), + ))); + group.replace(index, req.to_string()); + + Ok(()) + } + /// Adds a source to `tool.uv.sources`. fn add_source(&mut self, name: &PackageName, source: &Source) -> Result<(), Error> { // Get or create `tool.uv.sources`. @@ -356,7 +434,7 @@ impl PyProjectTomlMut { pub fn remove_dependency(&mut self, name: &PackageName) -> Result, Error> { // Try to get `project.dependencies`. let Some(dependencies) = self - .doc_mut()? + .project_mut()? .and_then(|project| project.get_mut("dependencies")) .map(|dependencies| { dependencies @@ -410,7 +488,7 @@ impl PyProjectTomlMut { ) -> Result, Error> { // Try to get `project.optional-dependencies.`. let Some(optional_dependencies) = self - .doc_mut()? + .project_mut()? .and_then(|project| project.get_mut("optional-dependencies")) .map(|extras| { extras @@ -435,6 +513,39 @@ impl PyProjectTomlMut { Ok(requirements) } + /// Removes all occurrences of the dependency in the group with the given name. + pub fn remove_dependency_group_requirement( + &mut self, + name: &PackageName, + group: &GroupName, + ) -> Result, Error> { + // Try to get `project.optional-dependencies.`. + let Some(group_dependencies) = self + .doc + .get_mut("dependency-groups") + .map(|groups| { + groups + .as_table_like_mut() + .ok_or(Error::MalformedDependencies) + }) + .transpose()? + .and_then(|groups| groups.get_mut(group.as_ref())) + .map(|dependencies| { + dependencies + .as_array_mut() + .ok_or(Error::MalformedDependencies) + }) + .transpose()? + else { + return Ok(Vec::new()); + }; + + let requirements = remove_dependency(name, group_dependencies); + self.remove_source(name)?; + + Ok(requirements) + } + /// Remove a matching source from `tool.uv.sources`, if it exists. fn remove_source(&mut self, name: &PackageName) -> Result<(), Error> { if let Some(sources) = self @@ -501,7 +612,7 @@ impl PyProjectTomlMut { let Some(dependencies) = dependencies.as_array() else { continue; }; - let Ok(group) = ExtraName::new(group.to_string()) else { + let Ok(group) = GroupName::new(group.to_string()) else { continue; }; diff --git a/crates/uv-workspace/src/workspace/tests.rs b/crates/uv-workspace/src/workspace/tests.rs index 67fa8f8c8f676..716b2e870f3fa 100644 --- a/crates/uv-workspace/src/workspace/tests.rs +++ b/crates/uv-workspace/src/workspace/tests.rs @@ -7,7 +7,7 @@ use assert_fs::fixture::ChildPath; use assert_fs::prelude::*; use insta::assert_json_snapshot; -use uv_pep508::ExtraName; +use uv_normalize::GroupName; use crate::pyproject::PyProjectToml; use crate::workspace::{DiscoveryOptions, ProjectWorkspace}; @@ -851,7 +851,7 @@ test = ["a"] .dependency_groups .expect("`dependency-groups` should be present"); let test = groups - .get(&ExtraName::from_str("test").unwrap()) + .get(&GroupName::from_str("test").unwrap()) .expect("Group `test` should be present"); assert_eq!(test, &["a".to_string()]); } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 785ab3cf1476b..bee4e98eef40b 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -14,8 +14,8 @@ use uv_cache::Cache; use uv_cache_key::RepositoryUrl; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{ - Concurrency, Constraints, DevMode, EditableMode, ExtrasSpecification, InstallOptions, - SourceStrategy, + Concurrency, Constraints, DevMode, DevSpecification, EditableMode, ExtrasSpecification, + InstallOptions, SourceStrategy, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -449,10 +449,12 @@ pub(crate) async fn add( let edit = match dependency_type { DependencyType::Production => toml.add_dependency(&requirement, source.as_ref())?, DependencyType::Dev => toml.add_dev_dependency(&requirement, source.as_ref())?, - DependencyType::Optional(ref group) => { - toml.add_optional_dependency(group, &requirement, source.as_ref())? + DependencyType::Optional(ref extra) => { + toml.add_optional_dependency(extra, &requirement, source.as_ref())? + } + DependencyType::Group(ref group) => { + toml.add_dependency_group_requirement(group, &requirement, source.as_ref())? } - DependencyType::Group(_) => todo!("adding dependencies to groups is not yet supported"), }; // If the edit was inserted before the end of the list, update the existing edits. @@ -709,11 +711,11 @@ async fn lock_and_sync( DependencyType::Dev => { toml.set_dev_dependency_minimum_version(*index, minimum)?; } - DependencyType::Optional(ref group) => { - toml.set_optional_dependency_minimum_version(group, *index, minimum)?; + DependencyType::Optional(ref extra) => { + toml.set_optional_dependency_minimum_version(extra, *index, minimum)?; } - DependencyType::Group(_) => { - todo!("adding dependencies to groups is not yet supported") + DependencyType::Group(ref group) => { + toml.set_dependency_group_requirement_minimum_version(group, *index, minimum)?; } } @@ -771,20 +773,24 @@ async fn lock_and_sync( let (extras, dev) = match dependency_type { DependencyType::Production => { let extras = ExtrasSpecification::None; - let dev = DevMode::Exclude; + let dev = DevSpecification::from(DevMode::Exclude); (extras, dev) } DependencyType::Dev => { let extras = ExtrasSpecification::None; - let dev = DevMode::Include; + let dev = DevSpecification::from(DevMode::Include); + (extras, dev) + } + DependencyType::Optional(ref extra_name) => { + let extras = ExtrasSpecification::Some(vec![extra_name.clone()]); + let dev = DevSpecification::from(DevMode::Exclude); (extras, dev) } - DependencyType::Optional(ref group_name) => { - let extras = ExtrasSpecification::Some(vec![group_name.clone()]); - let dev = DevMode::Exclude; + DependencyType::Group(ref group_name) => { + let extras = ExtrasSpecification::None; + let dev = DevSpecification::Include(vec![group_name.clone()]); (extras, dev) } - DependencyType::Group(_) => todo!("adding dependencies to groups is not yet supported"), }; project::sync::do_sync( @@ -792,7 +798,7 @@ async fn lock_and_sync( venv, &lock, &extras, - dev, + &dev, EditableMode::Editable, InstallOptions::default(), Modifications::Sufficient, diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index bb7db729bd9a3..d3525ebd6d514 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -11,7 +11,7 @@ use uv_configuration::{ Concurrency, DevMode, DevSpecification, EditableMode, ExportFormat, ExtrasSpecification, InstallOptions, }; -use uv_normalize::{PackageName, DEV_DEPENDENCIES}; +use uv_normalize::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_resolver::RequirementsTxtExport; use uv_workspace::{DiscoveryOptions, MemberDiscovery, VirtualProject, Workspace}; @@ -130,13 +130,6 @@ pub(crate) async fn export( Err(err) => return Err(err.into()), }; - // Include development dependencies, if requested. - let dev = match dev { - DevMode::Include => DevSpecification::Include(std::slice::from_ref(&DEV_DEPENDENCIES)), - DevMode::Exclude => DevSpecification::Exclude, - DevMode::Only => DevSpecification::Only(std::slice::from_ref(&DEV_DEPENDENCIES)), - }; - // Write the resolved dependencies to the output channel. let mut writer = OutputWriter::new(!quiet || output_file.is_none(), output_file.as_deref()); @@ -147,7 +140,7 @@ pub(crate) async fn export( &lock, project.project_name(), &extras, - dev, + &DevSpecification::from(dev), editable, hashes, &install_options, diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 98b568aeedfc4..530ab12fdaa9b 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -5,7 +5,9 @@ use std::path::Path; use owo_colors::OwoColorize; use uv_cache::Cache; use uv_client::Connectivity; -use uv_configuration::{Concurrency, DevMode, EditableMode, ExtrasSpecification, InstallOptions}; +use uv_configuration::{ + Concurrency, DevMode, DevSpecification, EditableMode, ExtrasSpecification, InstallOptions, +}; use uv_fs::Simplified; use uv_pep508::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; @@ -111,8 +113,8 @@ pub(crate) async fn remove( ); } } - DependencyType::Optional(ref group) => { - let deps = toml.remove_optional_dependency(&package, group)?; + DependencyType::Optional(ref extra) => { + let deps = toml.remove_optional_dependency(&package, extra)?; if deps.is_empty() { warn_if_present(&package, &toml); anyhow::bail!( @@ -120,8 +122,14 @@ pub(crate) async fn remove( ); } } - DependencyType::Group(_) => { - todo!("removing dependencies from groups is not yet supported") + DependencyType::Group(ref group) => { + let deps = toml.remove_dependency_group_requirement(&package, group)?; + if deps.is_empty() { + warn_if_present(&package, &toml); + anyhow::bail!( + "The dependency `{package}` could not be found in `dependency-groups`" + ); + } } } } @@ -205,7 +213,7 @@ pub(crate) async fn remove( &venv, &lock, &extras, - dev, + &DevSpecification::from(dev), EditableMode::Editable, install_options, Modifications::Exact, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index d8bef123e7472..9507fba1ee3d3 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -17,7 +17,8 @@ use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{ - Concurrency, DevMode, EditableMode, ExtrasSpecification, InstallOptions, SourceStrategy, + Concurrency, DevMode, DevSpecification, EditableMode, ExtrasSpecification, InstallOptions, + SourceStrategy, }; use uv_distribution::LoweredRequirement; use uv_fs::which::is_executable; @@ -572,7 +573,7 @@ pub(crate) async fn run( &venv, result.lock(), &extras, - dev, + &DevSpecification::from(dev), editable, install_options, Modifications::Sufficient, diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index b80f5b0efbbc0..aeffa1d705964 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -20,7 +20,7 @@ use uv_configuration::{ use uv_dispatch::BuildDispatch; use uv_distribution_types::{DirectorySourceDist, Dist, ResolvedDist, SourceDist}; use uv_installer::SitePackages; -use uv_normalize::{PackageName, DEV_DEPENDENCIES}; +use uv_normalize::PackageName; use uv_pep508::{MarkerTree, Requirement, VersionOrUrl}; use uv_pypi_types::{ LenientRequirement, ParsedArchiveUrl, ParsedGitUrl, ParsedUrl, VerbatimParsedUrl, @@ -150,7 +150,7 @@ pub(crate) async fn sync( &venv, &lock, &extras, - dev, + &DevSpecification::from(dev), editable, install_options, modifications, @@ -174,7 +174,7 @@ pub(super) async fn do_sync( venv: &PythonEnvironment, lock: &Lock, extras: &ExtrasSpecification, - dev: DevMode, + dev: &DevSpecification, editable: EditableMode, install_options: InstallOptions, modifications: Modifications, @@ -248,13 +248,6 @@ pub(super) async fn do_sync( } } - // Include development dependencies, if requested. - let dev = match dev { - DevMode::Include => DevSpecification::Include(std::slice::from_ref(&DEV_DEPENDENCIES)), - DevMode::Exclude => DevSpecification::Exclude, - DevMode::Only => DevSpecification::Only(std::slice::from_ref(&DEV_DEPENDENCIES)), - }; - // Determine the tags to use for resolution. let tags = venv.interpreter().tags()?; diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 0a0475939ff82..85775ad58c199 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -820,6 +820,7 @@ impl AddSettings { requirements, dev, optional, + group, editable, no_editable, extra, @@ -840,6 +841,8 @@ impl AddSettings { let dependency_type = if let Some(extra) = optional { DependencyType::Optional(extra) + } else if let Some(group) = group { + DependencyType::Group(group) } else if dev { DependencyType::Dev } else { @@ -895,6 +898,7 @@ impl RemoveSettings { dev, optional, packages, + group, no_sync, locked, frozen, @@ -906,8 +910,10 @@ impl RemoveSettings { python, } = args; - let dependency_type = if let Some(group) = optional { - DependencyType::Optional(group) + let dependency_type = if let Some(extra) = optional { + DependencyType::Optional(extra) + } else if let Some(group) = group { + DependencyType::Group(group) } else if dev { DependencyType::Dev } else { diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 70ef50b7e1b6f..7172cf4cb7b16 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -4056,6 +4056,230 @@ fn add_requirements_file() -> Result<()> { Ok(()) } +/// Add a requirement to a dependency group. +#[test] +fn add_group() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("anyio==3.7.0").arg("--group").arg("test"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "###); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [dependency-groups] + test = [ + "anyio==3.7.0", + ] + "### + ); + }); + + uv_snapshot!(context.filters(), context.add().arg("trio").arg("--group").arg("test"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "###); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [dependency-groups] + test = [ + "anyio==3.7.0", + "trio", + ] + "### + ); + }); + + uv_snapshot!(context.filters(), context.add().arg("anyio==3.7.0").arg("--group").arg("second"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "###); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [dependency-groups] + test = [ + "anyio==3.7.0", + "trio", + ] + second = [ + "anyio==3.7.0", + ] + "### + ); + }); + + assert!(context.temp_dir.join("uv.lock").exists()); + + Ok(()) +} + +/// Remomve a requirement from a dependency group. +#[test] +fn remove_group() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [dependency-groups] + test = [ + "anyio==3.7.0", + ] + "#})?; + + uv_snapshot!(context.filters(), context.remove().arg("anyio").arg("--group").arg("test"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Audited in [TIME] + "###); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [dependency-groups] + test = [] + "### + ); + }); + + uv_snapshot!(context.filters(), context.remove().arg("anyio").arg("--group").arg("test"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The dependency `anyio` could not be found in `dependency-groups` + "###); + + let pyproject_toml = context.read("pyproject.toml"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + pyproject_toml, @r###" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + + [dependency-groups] + test = [] + "### + ); + }); + + uv_snapshot!(context.filters(), context.remove().arg("anyio").arg("--group").arg("test"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: The dependency `anyio` could not be found in `dependency-groups` + "###); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["anyio"] + "#})?; + + uv_snapshot!(context.filters(), context.remove().arg("anyio").arg("--group").arg("test"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `anyio` is a production dependency + error: The dependency `anyio` could not be found in `dependency-groups` + "###); + + Ok(()) +} + /// Add to a PEP 732 script. #[test] fn add_script() -> Result<()> { diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 44e90cb5ec8df..553ea38ccfc44 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -4,6 +4,7 @@ use anyhow::Result; use assert_cmd::assert::OutputAssertExt; use assert_fs::{fixture::ChildPath, prelude::*}; use indoc::indoc; +use insta::assert_snapshot; use predicates::str::contains; use std::path::Path; @@ -957,6 +958,62 @@ fn run_locked() -> Result<()> { let existing = context.read("uv.lock"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + existing, @r###" + version = 1 + requires-python = ">=3.12" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "anyio" + version = "3.7.0" + source = { registry = "https://pypi.org/simple" } + dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + ] + sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/fefbf7e78ab3b805dec67d698dc18dd505af7a18a8dd08868c9b4fa736b5/anyio-3.7.0.tar.gz", hash = "sha256:275d9973793619a5374e1c89a4f4ad3f4b0a5510a2b5b939444bee8f4c4d37ce", size = 142737 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fe/7ce1926952c8a403b35029e194555558514b365ad77d75125f521a2bec62/anyio-3.7.0-py3-none-any.whl", hash = "sha256:eddca883c4175f14df8aedce21054bfca3adb70ffe76a9f607aef9d7fa2ea7f0", size = 80873 }, + ] + + [[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 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/e7/a82b05cf63a603df6e68d59ae6a68bf5064484a0718ea5033660af4b54a9/idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f", size = 61567 }, + ] + + [[package]] + name = "project" + version = "0.1.0" + source = { editable = "." } + dependencies = [ + { name = "anyio" }, + ] + + [package.metadata] + requires-dist = [{ name = "anyio", specifier = "==3.7.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 } + wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + ] + "###); + } + ); + // Update the requirements. pyproject_toml.write_str( r#" @@ -989,7 +1046,28 @@ fn run_locked() -> Result<()> { assert_eq!(existing, updated); // Lock the updated requirements. - context.lock().assert().success(); + uv_snapshot!(context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Removed anyio v3.7.0 + Removed idna v3.6 + Added iniconfig v2.0.0 + Removed sniffio v1.3.1 + "###); + + // Lock the updated requirements. + uv_snapshot!(context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + "###); // Running with `--locked` should succeed. uv_snapshot!(context.filters(), context.run().arg("--locked").arg("--").arg("python").arg("--version"), @r###" diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 5add2f5ddb46e..72da9d406f0ad 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -711,6 +711,10 @@ uv add [OPTIONS] >

The project environment will not be synced.

+
--group group

Add the requirements to the specified local dependency group.

+ +

These requirements will not be included in the published metadata for the project.

+
--help, -h

Display the concise help for this command

--index-strategy index-strategy

The strategy to use when resolving against multiple index URLs.

@@ -1029,6 +1033,8 @@ uv remove [OPTIONS] ...

The project environment will not be synced.

+
--group group

Remove the packages from the specified local dependency group

+
--help, -h

Display the concise help for this command

--index-strategy index-strategy

The strategy to use when resolving against multiple index URLs.