From b73b70550eb18525206fe4c6e11bcd07fe979831 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 16 Oct 2024 16:21:55 -0500 Subject: [PATCH] Add `--group` support to `uv add` and `uv remove` --- crates/uv-cli/src/lib.rs | 20 +- crates/uv-configuration/src/dev.rs | 24 +- crates/uv-distribution/src/metadata/mod.rs | 17 +- .../src/metadata/requires_dist.rs | 146 +++++++++--- crates/uv-normalize/src/group_name.rs | 11 +- crates/uv-resolver/src/lock/mod.rs | 2 +- .../uv-resolver/src/lock/requirements_txt.rs | 2 +- crates/uv-workspace/src/pyproject.rs | 6 +- crates/uv-workspace/src/pyproject_mut.rs | 133 ++++++++++- crates/uv-workspace/src/workspace/tests.rs | 4 +- crates/uv/src/commands/project/add.rs | 38 +-- crates/uv/src/commands/project/export.rs | 11 +- crates/uv/src/commands/project/remove.rs | 19 +- crates/uv/src/commands/project/run.rs | 6 +- crates/uv/src/commands/project/sync.rs | 13 +- crates/uv/src/settings.rs | 10 +- crates/uv/tests/it/edit.rs | 224 ++++++++++++++++++ crates/uv/tests/it/lock.rs | 2 +- crates/uv/tests/it/pip_compile.rs | 2 +- crates/uv/tests/it/run.rs | 80 ++++++- crates/uv/tests/it/workspace.rs | 2 +- docs/reference/cli.md | 6 + 22 files changed, 654 insertions(+), 124 deletions(-) diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 0177af3e7597..f1a0b2b72bec 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -15,7 +15,7 @@ use uv_configuration::{ ProjectBuildBackend, TargetTriple, TrustedHost, TrustedPublishing, VersionControlSystem, }; use uv_distribution_types::{Index, IndexUrl, Origin, PipExtraIndex, PipFindLinks, PipIndex}; -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}; @@ -2946,7 +2946,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. @@ -2956,9 +2956,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, @@ -3064,13 +3070,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 = EnvVars::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 ab11c55fadca..ba4be17210c5 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 141f777bf0fc..f40dcec29477 100644 --- a/crates/uv-distribution/src/metadata/mod.rs +++ b/crates/uv-distribution/src/metadata/mod.rs @@ -7,7 +7,8 @@ use uv_configuration::{LowerBound, SourceStrategy}; use uv_distribution_types::IndexLocations; 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; @@ -21,10 +22,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 e1b1d763d4ea..864b1da41b82 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -3,10 +3,13 @@ use crate::Metadata; use std::collections::BTreeMap; use std::path::Path; +use std::str::FromStr; + use uv_configuration::{LowerBound, SourceStrategy}; use uv_distribution_types::IndexLocations; 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)] @@ -97,48 +100,71 @@ 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, + requirements, + &metadata, project_sources, project_indexes, locations, - project_workspace.workspace(), + project_workspace, lower_bound, - ) - .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, + ) { + 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(); @@ -158,9 +184,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::, _>>()?, @@ -190,6 +217,49 @@ impl From for RequiresDist { } } +fn apply_source_strategy( + source_strategy: SourceStrategy, + requirements: Vec>, + metadata: &uv_pypi_types::RequiresDist, + project_sources: &BTreeMap, + project_indexes: &[uv_distribution_types::Index], + locations: &IndexLocations, + project_workspace: &ProjectWorkspace, + lower_bound: LowerBound, + group_name: &GroupName, +) -> 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(), + project_sources, + project_indexes, + locations, + project_workspace.workspace(), + lower_bound, + ) + .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; @@ -255,7 +325,7 @@ mod test { "#}; assert_snapshot!(format_err(input).await, @r###" - error: Failed to parse entry for: `tqdm` + error: Failed to parse entry: `tqdm` Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources` "###); } @@ -424,7 +494,7 @@ mod test { "#}; assert_snapshot!(format_err(input).await, @r###" - error: Failed to parse entry for: `tqdm` + error: Failed to parse entry: `tqdm` Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources` "###); } @@ -443,7 +513,7 @@ mod test { "#}; assert_snapshot!(format_err(input).await, @r###" - error: Failed to parse entry for: `tqdm` + error: Failed to parse entry: `tqdm` Caused by: Package is not included as workspace package in `tool.uv.workspace` "###); } diff --git a/crates/uv-normalize/src/group_name.rs b/crates/uv-normalize/src/group_name.rs index d41d3791cb45..72aa898ec729 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 b2ebe4dbed5c..f82520834dbe 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -550,7 +550,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 1393a459f5bd..b6f30385e3c2 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 3aed1b523863..fbedd796f712 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -19,7 +19,7 @@ use uv_distribution_types::Index; 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, @@ -1101,7 +1101,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 e5a10df23559..62d9c5455aef 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -6,6 +6,7 @@ use thiserror::Error; use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, RawString, Table, TomlError, Value}; use uv_distribution_types::Index; use uv_fs::PortablePath; +use uv_normalize::GroupName; use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers}; use uv_pep508::{ExtraName, MarkerTree, PackageName, Requirement, VersionOrUrl}; @@ -103,9 +104,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 @@ -120,7 +123,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 @@ -131,6 +136,7 @@ impl PyProjectTomlMut { }; Ok(doc) } + /// Adds a dependency to `project.dependencies`. /// /// Returns `true` if the dependency was added, `false` if it was updated. @@ -141,7 +147,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() @@ -284,7 +290,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() @@ -308,6 +314,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, @@ -316,7 +357,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() @@ -385,7 +426,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() @@ -413,6 +454,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`. @@ -439,7 +517,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 @@ -493,7 +571,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 @@ -518,6 +596,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 @@ -584,7 +695,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 294c54293197..cd3cbbf4c7b5 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}; @@ -866,7 +866,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 05620dee362c..958346b0054d 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -13,8 +13,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, - LowerBound, SourceStrategy, + Concurrency, Constraints, DevMode, DevSpecification, EditableMode, ExtrasSpecification, + InstallOptions, LowerBound, SourceStrategy, }; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; @@ -467,10 +467,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. @@ -741,11 +743,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)?; } } @@ -805,20 +807,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( @@ -826,7 +832,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 4cbccb67ed5d..8722cac05804 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, LowerBound, }; -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}; @@ -131,13 +131,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()); @@ -148,7 +141,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 628d06253fea..c717074f1a13 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -6,7 +6,8 @@ use owo_colors::OwoColorize; use uv_cache::Cache; use uv_client::Connectivity; use uv_configuration::{ - Concurrency, DevMode, EditableMode, ExtrasSpecification, InstallOptions, LowerBound, + Concurrency, DevMode, DevSpecification, EditableMode, ExtrasSpecification, InstallOptions, + LowerBound, }; use uv_fs::Simplified; use uv_pep508::PackageName; @@ -113,8 +114,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!( @@ -122,8 +123,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`" + ); + } } } } @@ -208,7 +215,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 38894f4c55de..e69cb2e4cbf1 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -17,8 +17,8 @@ use uv_cache::Cache; use uv_cli::ExternalCommand; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{ - Concurrency, DevMode, EditableMode, ExtrasSpecification, InstallOptions, LowerBound, - SourceStrategy, + Concurrency, DevMode, DevSpecification, EditableMode, ExtrasSpecification, InstallOptions, + LowerBound, SourceStrategy, }; use uv_distribution::LoweredRequirement; use uv_fs::which::is_executable; @@ -590,7 +590,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 52bee272bcbd..8379721ffc47 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -14,7 +14,7 @@ use uv_configuration::{ use uv_dispatch::BuildDispatch; use uv_distribution_types::{DirectorySourceDist, Dist, Index, 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, @@ -154,7 +154,7 @@ pub(crate) async fn sync( &venv, &lock, &extras, - dev, + &DevSpecification::from(dev), editable, install_options, modifications, @@ -178,7 +178,7 @@ pub(super) async fn do_sync( venv: &PythonEnvironment, lock: &Lock, extras: &ExtrasSpecification, - dev: DevMode, + dev: &DevSpecification, editable: EditableMode, install_options: InstallOptions, modifications: Modifications, @@ -252,13 +252,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 3337443e0790..0f917416968d 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -825,6 +825,7 @@ impl AddSettings { requirements, dev, optional, + group, editable, no_editable, extra, @@ -845,6 +846,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 { @@ -946,6 +949,7 @@ impl RemoveSettings { dev, optional, packages, + group, no_sync, locked, frozen, @@ -957,8 +961,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 b82add01967f..62853b486611 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -4061,6 +4061,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/lock.rs b/crates/uv/tests/it/lock.rs index 2ad0211d297d..fb6cd32f352a 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -8982,7 +8982,7 @@ fn lock_mismatched_sources() -> Result<()> { ----- stderr ----- error: Failed to build: `project @ file://[TEMP_DIR]/` - Caused by: Failed to parse entry for: `uv-public-pypackage` + Caused by: Failed to parse entry: `uv-public-pypackage` Caused by: Can't combine URLs from both `project.dependencies` and `tool.uv.sources` "###); diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index 964542dfe2da..713bf6720e0e 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -11647,7 +11647,7 @@ fn invalid_tool_uv_sources() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Failed to parse entry for: `urllib3` + error: Failed to parse entry: `urllib3` Caused by: Expected direct URL (`https://files.pythonhosted.org/packages/a2/73/a68704750a7679d0b6d3ad7aa8d4da8e14e151ae82e6fee774e6e0d05ec8/urllib3-2.2.1-py3-none-any.tar.baz`) to end in a supported file extension: `.whl`, `.tar.gz`, `.zip`, `.tar.bz2`, `.tar.lz`, `.tar.lzma`, `.tar.xz`, `.tar.zst`, `.tar`, `.tbz`, `.tgz`, `.tlz`, or `.txz` "### ); diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index f59837fb1705..76f7a2c268eb 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; @@ -958,6 +959,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#" @@ -990,7 +1047,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/crates/uv/tests/it/workspace.rs b/crates/uv/tests/it/workspace.rs index f66c856ece06..79c5ee47dda6 100644 --- a/crates/uv/tests/it/workspace.rs +++ b/crates/uv/tests/it/workspace.rs @@ -1656,7 +1656,7 @@ fn workspace_member_name_shadows_dependencies() -> Result<()> { ----- stderr ----- Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] error: Failed to build: `foo @ file://[TEMP_DIR]/workspace/packages/foo` - Caused by: Failed to parse entry for: `anyio` + Caused by: Failed to parse entry: `anyio` Caused by: Package is not included as workspace package in `tool.uv.workspace` "### ); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index b4f4bc5f4870..8984ff239059 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -749,6 +749,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 index

The URLs to use when resolving dependencies, in addition to the default index.

@@ -1081,6 +1085,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 index

The URLs to use when resolving dependencies, in addition to the default index.