From 70be4bc68b7cb35f18b53545f31b18e8235c7691 Mon Sep 17 00:00:00 2001 From: Ibraheem Ahmed Date: Fri, 14 Jun 2024 09:55:25 -0400 Subject: [PATCH] add support for adding/removing development dependencies --- crates/uv-distribution/src/pyproject_mut.rs | 139 ++++++----- crates/uv/src/cli.rs | 8 + crates/uv/src/commands/project/add.rs | 9 +- crates/uv/src/commands/project/remove.rs | 42 +++- crates/uv/src/main.rs | 2 + crates/uv/src/settings.rs | 6 + crates/uv/tests/common/mod.rs | 12 +- crates/uv/tests/edit.rs | 254 +++++++++++++++++++- 8 files changed, 399 insertions(+), 73 deletions(-) diff --git a/crates/uv-distribution/src/pyproject_mut.rs b/crates/uv-distribution/src/pyproject_mut.rs index 4e9215629d161..917cf9d80e0c1 100644 --- a/crates/uv-distribution/src/pyproject_mut.rs +++ b/crates/uv-distribution/src/pyproject_mut.rs @@ -2,7 +2,7 @@ use std::fmt; use std::str::FromStr; use thiserror::Error; -use toml_edit::{Array, DocumentMut, Item, RawString, TomlError, Value}; +use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value}; use pep508_rs::{PackageName, Requirement}; use pypi_types::VerbatimParsedUrl; @@ -33,79 +33,102 @@ impl PyProjectTomlMut { }) } - /// Adds a dependency. + /// Adds a dependency to `project.dependencies`. pub fn add_dependency(&mut self, req: &Requirement) -> Result<(), Error> { - let deps = &mut self.doc["project"]["dependencies"]; - if deps.is_none() { - *deps = Item::Value(Value::Array(Array::new())); - } - let deps = deps.as_array_mut().ok_or(Error::MalformedDependencies)?; - - // Try to find matching dependencies. - let mut to_replace = Vec::new(); - for (i, dep) in deps.iter().enumerate() { - if dep - .as_str() - .and_then(try_parse_requirement) - .filter(|dep| dep.name == req.name) - .is_some() - { - to_replace.push(i); - } - } + add_dependency(req, &mut self.doc["project"]["dependencies"]) + } - if to_replace.is_empty() { - deps.push(req.to_string()); - } else { - // Replace the first occurrence of the dependency and remove the rest. - deps.replace(to_replace[0], req.to_string()); - for &i in to_replace[1..].iter().rev() { - deps.remove(i); - } - } + /// Adds a development dependency to `tool.uv.dev-dependencies`. + pub fn add_dev_dependency(&mut self, req: &Requirement) -> Result<(), Error> { + let tool = self.doc["tool"].or_insert({ + let mut tool = Table::new(); + tool.set_implicit(true); + Item::Table(tool) + }); + let tool_uv = tool["uv"].or_insert(Item::Table(Table::new())); - reformat_array_multiline(deps); - Ok(()) + add_dependency(req, &mut tool_uv["dev-dependencies"]) } /// Removes all occurrences of dependencies with the given name. pub fn remove_dependency(&mut self, req: &PackageName) -> Result, Error> { - let deps = &mut self.doc["project"]["dependencies"]; - if deps.is_none() { + remove_dependency(req, &mut self.doc["project"]["dependencies"]) + } + + /// Removes all occurrences of development dependencies with the given name. + pub fn remove_dev_dependency(&mut self, req: &PackageName) -> Result, Error> { + let Some(tool_uv) = self.doc.get_mut("tool").and_then(|tool| tool.get_mut("uv")) else { return Ok(Vec::new()); + }; + + remove_dependency(req, &mut tool_uv["dev-dependencies"]) + } +} + +/// Adds a dependency to the given `deps` array. +pub fn add_dependency(req: &Requirement, deps: &mut Item) -> Result<(), Error> { + let deps = deps + .or_insert(Item::Value(Value::Array(Array::new()))) + .as_array_mut() + .ok_or(Error::MalformedDependencies)?; + + // Find matching dependencies. + let to_replace = find_dependencies(&req.name, deps); + + if to_replace.is_empty() { + deps.push(req.to_string()); + } else { + // Replace the first occurrence of the dependency and remove the rest. + deps.replace(to_replace[0], req.to_string()); + for &i in to_replace[1..].iter().rev() { + deps.remove(i); } + } + + reformat_array_multiline(deps); + Ok(()) +} - let deps = deps.as_array_mut().ok_or(Error::MalformedDependencies)?; +/// Removes all occurrences of dependencies with the given name from the given `deps` array. +fn remove_dependency(req: &PackageName, deps: &mut Item) -> Result, Error> { + if deps.is_none() { + return Ok(Vec::new()); + } - // Try to find matching dependencies. - let mut to_remove = Vec::new(); - for (i, dep) in deps.iter().enumerate() { - if dep + let deps = deps.as_array_mut().ok_or(Error::MalformedDependencies)?; + + // Remove matching dependencies. + let removed = find_dependencies(req, deps) + .into_iter() + .rev() // Reverse to preserve indices as we remove them. + .filter_map(|i| { + deps.remove(i) .as_str() - .and_then(try_parse_requirement) - .filter(|dep| dep.name == *req) - .is_some() - { - to_remove.push(i); - } - } + .and_then(|req| Requirement::from_str(req).ok()) + }) + .collect::>(); - let removed = to_remove - .into_iter() - .rev() // Reverse to preserve indices as we remove them. - .filter_map(|i| { - deps.remove(i) - .as_str() - .and_then(|req| Requirement::from_str(req).ok()) - }) - .collect::>(); + if !removed.is_empty() { + reformat_array_multiline(deps); + } - if !removed.is_empty() { - reformat_array_multiline(deps); - } + Ok(removed) +} - Ok(removed) +// Returns a `Vec` containing the indices of all dependencies with the given name. +fn find_dependencies(name: &PackageName, deps: &Array) -> Vec { + let mut to_replace = Vec::new(); + for (i, dep) in deps.iter().enumerate() { + if dep + .as_str() + .and_then(try_parse_requirement) + .filter(|dep| dep.name == *name) + .is_some() + { + to_replace.push(i); + } } + to_replace } impl fmt::Display for PyProjectTomlMut { diff --git a/crates/uv/src/cli.rs b/crates/uv/src/cli.rs index 81233865aee9f..68d67e46f7a0f 100644 --- a/crates/uv/src/cli.rs +++ b/crates/uv/src/cli.rs @@ -1602,6 +1602,10 @@ pub(crate) struct AddArgs { #[arg(required = true)] pub(crate) requirements: Vec, + /// Add the requirements as development dependencies. + #[arg(long)] + pub(crate) dev: bool, + #[command(flatten)] pub(crate) resolver: ResolverArgs, @@ -1634,6 +1638,10 @@ pub(crate) struct RemoveArgs { #[arg(required = true)] pub(crate) requirements: Vec, + /// Remove the requirements from development dependencies. + #[arg(long)] + pub(crate) dev: bool, + /// The Python interpreter into which packages should be installed. /// /// By default, `uv` installs into the virtual environment in the current working directory or diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 14241b0fb1a73..4e0e1b5bc85ba 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -24,6 +24,7 @@ use crate::settings::{InstallerSettings, ResolverSettings}; #[allow(clippy::too_many_arguments)] pub(crate) async fn add( requirements: Vec, + dev: bool, python: Option, settings: ResolverSettings, preview: PreviewMode, @@ -125,8 +126,12 @@ pub(crate) async fn add( // Add the requirements to the `pyproject.toml`. let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; - for req in requirements { - pyproject.add_dependency(&pep508_rs::Requirement::from(req))?; + for req in requirements.into_iter().map(pep508_rs::Requirement::from) { + if dev { + pyproject.add_dev_dependency(&req)?; + } else { + pyproject.add_dependency(&req)?; + } } // Save the modified `pyproject.toml`. diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 03051bbb5fea4..5544c48cc5ff4 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -16,6 +16,7 @@ use crate::settings::{InstallerSettings, ResolverSettings}; #[allow(clippy::too_many_arguments)] pub(crate) async fn remove( requirements: Vec, + dev: bool, python: Option, preview: PreviewMode, connectivity: Connectivity, @@ -33,12 +34,43 @@ pub(crate) async fn remove( let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; for req in requirements { - if pyproject.remove_dependency(&req)?.is_empty() { - anyhow::bail!( - "The dependency `{}` could not be found in `dependencies`", - req - ); + if dev { + let deps = pyproject.remove_dev_dependency(&req)?; + if deps.is_empty() { + // Check if there is a matching regular dependency. + if pyproject + .remove_dependency(&req) + .ok() + .filter(|deps| !deps.is_empty()) + .is_some() + { + uv_warnings::warn_user!("`{req}` is not a development dependency; try calling `uv add` without the `--dev` flag"); + } + + anyhow::bail!("The dependency `{req}` could not be found in `dev-dependencies`"); + } + + continue; + } + + let deps = pyproject.remove_dependency(&req)?; + if deps.is_empty() { + // Check if there is a matching development dependency. + if pyproject + .remove_dev_dependency(&req) + .ok() + .filter(|deps| !deps.is_empty()) + .is_some() + { + uv_warnings::warn_user!( + "`{req}` is a development dependency; try calling `uv add --dev`" + ); + } + + anyhow::bail!("The dependency `{req}` could not be found in `dependencies`"); } + + continue; } // Save the modified `pyproject.toml`. diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index 8901d75bab5ef..f53afd699767b 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -693,6 +693,7 @@ async fn run() -> Result { commands::add( requirements, + args.dev, args.python, args.settings, globals.preview, @@ -714,6 +715,7 @@ async fn run() -> Result { commands::remove( args.requirements, + args.dev, args.python, globals.preview, globals.connectivity, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index aec8fb5990b76..68ea010c5c76d 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -346,6 +346,7 @@ impl LockSettings { #[derive(Debug, Clone)] pub(crate) struct AddSettings { pub(crate) requirements: Vec, + pub(crate) dev: bool, pub(crate) python: Option, pub(crate) refresh: Refresh, pub(crate) settings: ResolverSettings, @@ -357,6 +358,7 @@ impl AddSettings { pub(crate) fn resolve(args: AddArgs, filesystem: Option) -> Self { let AddArgs { requirements, + dev, resolver, build, refresh, @@ -365,6 +367,7 @@ impl AddSettings { Self { requirements, + dev, python, refresh: Refresh::from(refresh), settings: ResolverSettings::combine(resolver_options(resolver, build), filesystem), @@ -377,6 +380,7 @@ impl AddSettings { #[derive(Debug, Clone)] pub(crate) struct RemoveSettings { pub(crate) requirements: Vec, + pub(crate) dev: bool, pub(crate) python: Option, } @@ -385,12 +389,14 @@ impl RemoveSettings { #[allow(clippy::needless_pass_by_value)] pub(crate) fn resolve(args: RemoveArgs, _filesystem: Option) -> Self { let RemoveArgs { + dev, requirements, python, } = args; Self { requirements, + dev, python, } } diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 31633bd54788a..1f1450b10f195 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -321,7 +321,7 @@ impl TestContext { } /// Create a `uv add` command for the given requirements. - pub fn add(&self, reqs: &[&str]) -> std::process::Command { + pub fn add(&self, reqs: &[&str], dev: bool) -> std::process::Command { let mut command = std::process::Command::new(get_bin()); command .arg("add") @@ -332,6 +332,10 @@ impl TestContext { .env("UV_NO_WRAP", "1") .current_dir(&self.temp_dir); + if dev { + command.arg("--dev"); + } + if cfg!(all(windows, debug_assertions)) { // TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the // default windows stack of 1MB @@ -342,7 +346,7 @@ impl TestContext { } /// Create a `uv remove` command for the given requirements. - pub fn remove(&self, reqs: &[&str]) -> std::process::Command { + pub fn remove(&self, reqs: &[&str], dev: bool) -> std::process::Command { let mut command = std::process::Command::new(get_bin()); command .arg("remove") @@ -353,6 +357,10 @@ impl TestContext { .env("UV_NO_WRAP", "1") .current_dir(&self.temp_dir); + if dev { + command.arg("--dev"); + } + if cfg!(all(windows, debug_assertions)) { // TODO(konstin): Reduce stack usage in debug mode enough that the tests pass with the // default windows stack of 1MB diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 553f83a64316c..28918b26ccd21 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -24,7 +24,7 @@ fn add_registry() -> Result<()> { dependencies = [] "#})?; - uv_snapshot!(context.filters(), context.add(&["anyio==3.7.0"]), @r###" + uv_snapshot!(context.filters(), context.add(&["anyio==3.7.0"], false), @r###" success: true exit_code: 0 ----- stdout ----- @@ -167,7 +167,7 @@ fn add_git() -> Result<()> { + sniffio==1.3.1 "###); - uv_snapshot!(context.filters(), context.add(&["uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage@0.0.1"]), @r###" + uv_snapshot!(context.filters(), context.add(&["uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage@0.0.1"], false), @r###" success: true exit_code: 0 ----- stdout ----- @@ -297,7 +297,7 @@ fn add_unnamed() -> Result<()> { dependencies = [] "#})?; - uv_snapshot!(context.filters(), context.add(&["git+https://github.com/astral-test/uv-public-pypackage@0.0.1"]), @r###" + uv_snapshot!(context.filters(), context.add(&["git+https://github.com/astral-test/uv-public-pypackage@0.0.1"], false), @r###" success: true exit_code: 0 ----- stdout ----- @@ -374,6 +374,128 @@ fn add_unnamed() -> Result<()> { Ok(()) } +/// Add a development dependency. +#[test] +fn add_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 = [] + "#})?; + + uv_snapshot!(context.filters(), context.add(&["anyio==3.7.0"], true), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning. + Resolved 4 packages in [TIME] + Downloaded 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==3.7.0 + + idna==3.7 + + project==0.1.0 (from file://[TEMP_DIR]/) + + sniffio==1.3.1 + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("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", + ] + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[distribution]] + name = "anyio" + version = "3.7.0" + source = "registry+https://pypi.org/simple" + 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 }] + + [[distribution.dependencies]] + name = "idna" + version = "3.7" + source = "registry+https://pypi.org/simple" + + [[distribution.dependencies]] + name = "sniffio" + version = "1.3.1" + source = "registry+https://pypi.org/simple" + + [[distribution]] + name = "idna" + version = "3.7" + source = "registry+https://pypi.org/simple" + sdist = { url = "https://files.pythonhosted.org/packages/21/ed/f86a79a07470cb07819390452f178b3bef1d375f2ec021ecfc709fc7cf07/idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", size = 189575 } + wheels = [{ url = "https://files.pythonhosted.org/packages/e5/3e/741d8c82801c347547f8a2a06aa57dbb1992be9e948df2ea0eda2c8b79e8/idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0", size = 66836 }] + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+." + sdist = { path = "." } + + [distribution.dev-dependencies] + + [[distribution.dev-dependencies.dev]] + name = "anyio" + version = "3.7.0" + source = "registry+https://pypi.org/simple" + + [[distribution]] + 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 }] + "### + ); + }); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Audited 4 packages in [TIME] + "###); + + Ok(()) +} + /// Update a PyPI requirement. #[test] fn update_registry() -> Result<()> { @@ -416,7 +538,7 @@ fn update_registry() -> Result<()> { + sniffio==1.3.1 "###); - uv_snapshot!(context.filters(), context.add(&["anyio==4.3.0"]), @r###" + uv_snapshot!(context.filters(), context.add(&["anyio==4.3.0"], false), @r###" success: true exit_code: 0 ----- stdout ----- @@ -559,7 +681,7 @@ fn remove_registry() -> Result<()> { + sniffio==1.3.1 "###); - uv_snapshot!(context.filters(), context.remove(&["anyio"]), @r###" + uv_snapshot!(context.filters(), context.remove(&["anyio"], false), @r###" success: true exit_code: 0 ----- stdout ----- @@ -623,6 +745,126 @@ fn remove_registry() -> Result<()> { Ok(()) } +/// Remove a development dependency. +#[test] +fn remove_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==3.7.0"] + "#})?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv lock` is experimental and may change without warning. + Resolved 4 packages in [TIME] + "###); + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Downloaded 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 + "###); + + uv_snapshot!(context.filters(), context.remove(&["anyio"], false), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv remove` is experimental and may change without warning. + warning: `anyio` is a development dependency; try calling `uv add --dev` + error: The dependency `anyio` could not be found in `dependencies` + "###); + + uv_snapshot!(context.filters(), context.remove(&["anyio"], true), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv remove` is experimental and may change without warning. + Resolved 1 package in [TIME] + Downloaded 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - project==0.1.0 (from file://[TEMP_DIR]/) + + project==0.1.0 (from file://[TEMP_DIR]/) + "###); + + let pyproject_toml = fs_err::read_to_string(context.temp_dir.join("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 = [] + "### + ); + }); + + let lock = fs_err::read_to_string(context.temp_dir.join("uv.lock"))?; + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r###" + version = 1 + requires-python = ">=3.12" + + [[distribution]] + name = "project" + version = "0.1.0" + source = "editable+." + sdist = { path = "." } + "### + ); + }); + + // Install from the lockfile. + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv sync` is experimental and may change without warning. + Audited 1 package in [TIME] + "###); + + Ok(()) +} + /// Remove a PyPI requirement that occurs multiple times. #[test] fn remove_all_registry() -> Result<()> { @@ -665,7 +907,7 @@ fn remove_all_registry() -> Result<()> { + sniffio==1.3.1 "###); - uv_snapshot!(context.filters(), context.remove(&["anyio"]), @r###" + uv_snapshot!(context.filters(), context.remove(&["anyio"], false), @r###" success: true exit_code: 0 ----- stdout -----