Add to dependency-groups.dev in uv add --dev (#8570)

## Summary

`uv add --dev` now updates the `dependency-groups.dev` section, rather
than `tool.uv.dev-dependencies` -- unless the dependency is already
present in `tool.uv.dev-dependencies`.

`uv remove --dev` now removes from both `dependency-groups.dev` and
`tool.uv.dev-dependencies`.

`--dev` and `--group dev` are now treated equivalently in `uv add` and
`uv remove`.
This commit is contained in:
Charlie Marsh
2024-10-25 14:06:34 -04:00
committed by Zanie Blue
parent 810b430031
commit 07b6887c08
4 changed files with 519 additions and 31 deletions
+20
View File
@@ -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.
///
+61 -8
View File
@@ -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<Source>,
edit: ArrayEdit,
+28 -11
View File
@@ -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}`",
);
}
}
}
+410 -12
View File
@@ -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]",
]
"###
);
});