diff --git a/crates/uv-workspace/src/pyproject_mut.rs b/crates/uv-workspace/src/pyproject_mut.rs index 3d9774199..e01ecf3ba 100644 --- a/crates/uv-workspace/src/pyproject_mut.rs +++ b/crates/uv-workspace/src/pyproject_mut.rs @@ -782,6 +782,26 @@ impl PyProjectTomlMut { Ok(()) } + /// Returns `true` if the `tool.uv.dev-dependencies` table is present. + pub fn has_dev_dependencies(&self) -> bool { + self.doc + .get("tool") + .and_then(Item::as_table) + .and_then(|tool| tool.get("uv")) + .and_then(Item::as_table) + .and_then(|uv| uv.get("dev-dependencies")) + .is_some() + } + + /// Returns `true` if the `dependency-groups` table is present and contains the given group. + pub fn has_dependency_group(&self, group: &GroupName) -> bool { + self.doc + .get("dependency-groups") + .and_then(Item::as_table) + .and_then(|groups| groups.get(group.as_ref())) + .is_some() + } + /// Returns all the places in this `pyproject.toml` that contain a dependency with the given /// name. /// diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 772b03017..eb860a67e 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -21,7 +21,7 @@ use uv_distribution::DistributionDatabase; use uv_distribution_types::{Index, IndexName, UnresolvedRequirement, VersionId}; use uv_fs::Simplified; use uv_git::{GitReference, GIT_STORE}; -use uv_normalize::PackageName; +use uv_normalize::{PackageName, DEV_DEPENDENCIES}; use uv_pep508::{ExtraName, Requirement, UnnamedRequirement, VersionOrUrl}; use uv_pypi_types::{redact_credentials, ParsedUrl, RequirementSource, VerbatimParsedUrl}; use uv_python::{ @@ -203,8 +203,12 @@ pub(crate) async fn add( DependencyType::Optional(_) => { bail!("Project is missing a `[project]` table; add a `[project]` table to use optional dependencies, or run `{}` instead", "uv add --dev".green()) } + DependencyType::Group(_) => { + // TODO(charlie): Allow adding to `dependency-groups` in non-`[project]` + // targets, per PEP 735. + bail!("Project is missing a `[project]` table; add a `[project]` table to use `dependency-groups` dependencies, or run `{}` instead", "uv add --dev".green()) + } DependencyType::Dev => (), - DependencyType::Group(_) => (), } } @@ -463,8 +467,49 @@ pub(crate) async fn add( _ => source, }; + // Determine the dependency type. + let dependency_type = match &dependency_type { + DependencyType::Dev => { + let existing = toml.find_dependency(&requirement.name, None); + if existing.iter().any(|dependency_type| matches!(dependency_type, DependencyType::Group(group) if group == &*DEV_DEPENDENCIES)) { + // If the dependency already exists in `dependency-groups.dev`, use that. + DependencyType::Group(DEV_DEPENDENCIES.clone()) + } else if existing.iter().any(|dependency_type| matches!(dependency_type, DependencyType::Dev)) { + // If the dependency already exists in `dev-dependencies`, use that. + DependencyType::Dev + } else if target.as_project().is_some_and(uv_workspace::VirtualProject::is_non_project) { + // TODO(charlie): Allow adding to `dependency-groups` in non-`[project]` targets. + DependencyType::Dev + } else { + // Otherwise, use `dependency-groups.dev`, unless it would introduce a separate table. + match (toml.has_dev_dependencies(), toml.has_dependency_group(&DEV_DEPENDENCIES)) { + (true, false) => DependencyType::Dev, + (false, true) => DependencyType::Group(DEV_DEPENDENCIES.clone()), + (true, true) => DependencyType::Group(DEV_DEPENDENCIES.clone()), + (false, false) => DependencyType::Group(DEV_DEPENDENCIES.clone()), + } + } + } + DependencyType::Group(group) if group == &*DEV_DEPENDENCIES => { + let existing = toml.find_dependency(&requirement.name, None); + if existing.iter().any(|dependency_type| matches!(dependency_type, DependencyType::Group(group) if group == &*DEV_DEPENDENCIES)) { + // If the dependency already exists in `dependency-groups.dev`, use that. + DependencyType::Group(DEV_DEPENDENCIES.clone()) + } else if existing.iter().any(|dependency_type| matches!(dependency_type, DependencyType::Dev)) { + // If the dependency already exists in `dev-dependencies`, use that. + DependencyType::Dev + } else { + // Otherwise, use `dependency-groups.dev`. + DependencyType::Group(DEV_DEPENDENCIES.clone()) + } + } + DependencyType::Production => DependencyType::Production, + DependencyType::Optional(extra) => DependencyType::Optional(extra.clone()), + DependencyType::Group(group) => DependencyType::Group(group.clone()), + }; + // Update the `pyproject.toml`. - let edit = match dependency_type { + 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 extra) => { @@ -478,7 +523,7 @@ pub(crate) async fn add( // If the edit was inserted before the end of the list, update the existing edits. if let ArrayEdit::Add(index) = &edit { for edit in &mut edits { - if *edit.dependency_type == dependency_type { + if edit.dependency_type == dependency_type { match &mut edit.edit { ArrayEdit::Add(existing) => { if *existing >= *index { @@ -496,7 +541,7 @@ pub(crate) async fn add( } edits.push(DependencyEdit { - dependency_type: &dependency_type, + dependency_type, requirement, source, edit, @@ -646,7 +691,7 @@ pub(crate) async fn add( async fn lock_and_sync( mut project: VirtualProject, toml: &mut PyProjectTomlMut, - edits: &[DependencyEdit<'_>], + edits: &[DependencyEdit], venv: &PythonEnvironment, state: SharedState, locked: bool, @@ -973,6 +1018,14 @@ enum Target { } impl Target { + /// Returns the [`VirtualProject`] for the target, if it is a project. + fn as_project(&self) -> Option<&VirtualProject> { + match self { + Self::Project(project, _) => Some(project), + Self::Script(_, _) => None, + } + } + /// Returns the [`Interpreter`] for the target. fn interpreter(&self) -> &Interpreter { match self { @@ -983,8 +1036,8 @@ impl Target { } #[derive(Debug, Clone)] -struct DependencyEdit<'a> { - dependency_type: &'a DependencyType, +struct DependencyEdit { + dependency_type: DependencyType, requirement: Requirement, source: Option, edit: ArrayEdit, diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index a42e2a2c8..df8d671f2 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -9,6 +9,7 @@ use uv_configuration::{ Concurrency, DevGroupsManifest, EditableMode, ExtrasSpecification, InstallOptions, LowerBound, }; use uv_fs::Simplified; +use uv_normalize::DEV_DEPENDENCIES; use uv_pep508::PackageName; use uv_python::{PythonDownloads, PythonPreference, PythonRequest}; use uv_scripts::Pep723Script; @@ -106,11 +107,13 @@ pub(crate) async fn remove( } } DependencyType::Dev => { - let deps = toml.remove_dev_dependency(&package)?; - if deps.is_empty() { + let dev_deps = toml.remove_dev_dependency(&package)?; + let group_deps = + toml.remove_dependency_group_requirement(&package, &DEV_DEPENDENCIES)?; + if dev_deps.is_empty() && group_deps.is_empty() { warn_if_present(&package, &toml); anyhow::bail!( - "The dependency `{package}` could not be found in `dev-dependencies`" + "The dependency `{package}` could not be found in `dev-dependencies` or `dependency-groups.dev`" ); } } @@ -124,12 +127,24 @@ pub(crate) async fn remove( } } 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`" - ); + if group == &*DEV_DEPENDENCIES { + let dev_deps = toml.remove_dev_dependency(&package)?; + let group_deps = + toml.remove_dependency_group_requirement(&package, &DEV_DEPENDENCIES)?; + if dev_deps.is_empty() && group_deps.is_empty() { + warn_if_present(&package, &toml); + anyhow::bail!( + "The dependency `{package}` could not be found in `dev-dependencies` or `dependency-groups.dev`" + ); + } + } else { + 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`" + ); + } } } } @@ -262,8 +277,10 @@ fn warn_if_present(name: &PackageName, pyproject: &PyProjectTomlMut) { "`{name}` is an optional dependency; try calling `uv remove --optional {group}`", ); } - DependencyType::Group(_) => { - // TODO(zanieb): Once we support `remove --group`, add a warning here. + DependencyType::Group(group) => { + warn_user!( + "`{name}` is in the `{group}` group; try calling `uv remove --group {group}`", + ); } } } diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 53fee8472..2b59e554a 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -1025,8 +1025,8 @@ fn add_remove_dev() -> Result<()> { requires = ["setuptools>=42"] build-backend = "setuptools.build_meta" - [tool.uv] - dev-dependencies = [ + [dependency-groups] + dev = [ "anyio==3.7.0", ] "### @@ -1113,7 +1113,7 @@ fn add_remove_dev() -> Result<()> { ----- stdout ----- ----- stderr ----- - warning: `anyio` is a development dependency; try calling `uv remove --dev` + warning: `anyio` is in the `dev` group; try calling `uv remove --group dev` error: The dependency `anyio` could not be found in `dependencies` "###); @@ -1151,8 +1151,8 @@ fn add_remove_dev() -> Result<()> { requires = ["setuptools>=42"] build-backend = "setuptools.build_meta" - [tool.uv] - dev-dependencies = [] + [dependency-groups] + dev = [] "### ); }); @@ -1737,6 +1737,404 @@ fn add_remove_workspace() -> Result<()> { Ok(()) } +/// `uv add --dev` should update `dev-dependencies` (rather than `dependency-group.dev`) if a +/// dependency already exists in `dev-dependencies`. +#[test] +fn update_existing_dev() -> 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 = [] + + [tool.uv] + dev-dependencies = ["anyio"] + + [dependency-groups] + dev = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("anyio==3.7.0").arg("--dev"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + 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 = [] + + [tool.uv] + dev-dependencies = [ + "anyio==3.7.0", + ] + + [dependency-groups] + dev = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "### + ); + }); + + Ok(()) +} + +/// `uv add --dev` should add to `dev-dependencies` (rather than `dependency-group.dev`) if it +/// exists. +#[test] +fn add_existing_dev() -> 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 = [] + + [tool.uv] + dev-dependencies = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("anyio==3.7.0").arg("--dev"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + 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 = [] + + [tool.uv] + dev-dependencies = [ + "anyio==3.7.0", + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "### + ); + }); + + Ok(()) +} + +/// `uv add --group dev` should update `dev-dependencies` (rather than `dependency-group.dev`) if a +/// dependency already exists. +#[test] +fn update_existing_dev_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 = [] + + [tool.uv] + dev-dependencies = ["anyio"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("anyio==3.7.0").arg("--group").arg("dev"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + 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 = [] + + [tool.uv] + dev-dependencies = [ + "anyio==3.7.0", + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "### + ); + }); + + Ok(()) +} + +/// `uv add --group dev` should add to `dependency-group` even if `dev-dependencies` exists. +#[test] +fn add_existing_dev_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 = [] + + [tool.uv] + dev-dependencies = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("anyio==3.7.0").arg("--group").arg("dev"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 4 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + 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 = [] + + [tool.uv] + dev-dependencies = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + + [dependency-groups] + dev = [ + "anyio==3.7.0", + ] + "### + ); + }); + + Ok(()) +} + +/// `uv remove --dev` should remove from both `dev-dependencies` and `dependency-group.dev`. +#[test] +fn remove_both_dev() -> 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 = [] + + [tool.uv] + dev-dependencies = ["anyio"] + + [dependency-groups] + dev = ["anyio>=3.7.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#})?; + + uv_snapshot!(context.filters(), context.remove().arg("anyio").arg("--dev"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + 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 = [] + + [tool.uv] + dev-dependencies = [] + + [dependency-groups] + dev = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "### + ); + }); + + Ok(()) +} + +/// `uv remove --group dev` should remove from both `dev-dependencies` and `dependency-group.dev`. +#[test] +fn remove_both_dev_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 = [] + + [tool.uv] + dev-dependencies = ["anyio"] + + [dependency-groups] + dev = ["anyio>=3.7.0"] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#})?; + + uv_snapshot!(context.filters(), context.remove().arg("anyio").arg("--group").arg("dev"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + 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 = [] + + [tool.uv] + dev-dependencies = [] + + [dependency-groups] + dev = [] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "### + ); + }); + + Ok(()) +} + /// Add a workspace dependency as an editable. #[test] fn add_workspace_editable() -> Result<()> { @@ -3718,8 +4116,8 @@ fn add_lower_bound_dev() -> Result<()> { requires = ["setuptools>=42"] build-backend = "setuptools.build_meta" - [tool.uv] - dev-dependencies = [ + [dependency-groups] + dev = [ "anyio>=4.3.0", ] "### @@ -6677,13 +7075,13 @@ fn add_self() -> Result<()> { requires = ["setuptools>=42"] build-backend = "setuptools.build_meta" - [tool.uv] - dev-dependencies = [ - "anyio[types]", - ] - [tool.uv.sources] anyio = { workspace = true } + + [dependency-groups] + dev = [ + "anyio[types]", + ] "### ); });