diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 2a1a870ae329..9ff0d8ab7ccd 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1763,9 +1763,13 @@ pub struct AddArgs { pub requirements: Vec, /// Add the requirements as development dependencies. - #[arg(long)] + #[arg(long, conflicts_with("optional"))] pub dev: bool, + /// Add the requirements to the specified optional dependency group. + #[arg(long, conflicts_with("dev"))] + pub optional: Option, + /// Add the requirements as editables. #[arg(long, default_missing_value = "true", num_args(0..=1))] pub editable: Option, @@ -1829,9 +1833,13 @@ pub struct RemoveArgs { pub requirements: Vec, /// Remove the requirements from development dependencies. - #[arg(long)] + #[arg(long, conflicts_with("optional"))] pub dev: bool, + /// Remove the requirements from the specified optional dependency group. + #[arg(long, conflicts_with("dev"))] + pub optional: Option, + /// Remove the dependency from a specific package in the workspace. #[arg(long, conflicts_with = "isolated")] pub package: Option, diff --git a/crates/uv-distribution/src/pyproject.rs b/crates/uv-distribution/src/pyproject.rs index 9e77e41a0160..1892b7b75a0b 100644 --- a/crates/uv-distribution/src/pyproject.rs +++ b/crates/uv-distribution/src/pyproject.rs @@ -289,6 +289,17 @@ impl Source { } } +/// The type of a dependency in a `pyproject.toml`. +#[derive(Debug, Clone)] +pub enum DependencyType { + /// A dependency in `project.dependencies`. + Production, + /// A dependency in `tool.uv.dev-dependencies`. + Dev, + /// A dependency in `project.optional-dependencies.{0}`. + Optional(ExtraName), +} + /// mod serde_from_and_to_string { use std::fmt::Display; diff --git a/crates/uv-distribution/src/pyproject_mut.rs b/crates/uv-distribution/src/pyproject_mut.rs index d085ea10fa03..24ac450a48c3 100644 --- a/crates/uv-distribution/src/pyproject_mut.rs +++ b/crates/uv-distribution/src/pyproject_mut.rs @@ -4,9 +4,9 @@ use std::{fmt, mem}; use thiserror::Error; use toml_edit::{Array, DocumentMut, Item, RawString, Table, TomlError, Value}; -use pep508_rs::{PackageName, Requirement, VersionOrUrl}; +use pep508_rs::{ExtraName, PackageName, Requirement, VersionOrUrl}; -use crate::pyproject::{PyProjectToml, Source}; +use crate::pyproject::{DependencyType, PyProjectToml, Source}; /// Raw and mutable representation of a `pyproject.toml`. /// @@ -60,23 +60,7 @@ impl PyProjectTomlMut { add_dependency(req, dependencies, source.is_some())?; if let Some(source) = source { - // Get or create `tool.uv.sources`. - let sources = self - .doc - .entry("tool") - .or_insert(implicit()) - .as_table_mut() - .ok_or(Error::MalformedSources)? - .entry("uv") - .or_insert(implicit()) - .as_table_mut() - .ok_or(Error::MalformedSources)? - .entry("sources") - .or_insert(Item::Table(Table::new())) - .as_table_mut() - .ok_or(Error::MalformedSources)?; - - add_source(&name, &source, sources)?; + self.add_source(&name, &source)?; } Ok(()) @@ -88,8 +72,8 @@ impl PyProjectTomlMut { req: Requirement, source: Option, ) -> Result<(), Error> { - // Get or create `tool.uv`. - let tool_uv = self + // Get or create `tool.uv.dev-dependencies`. + let dev_dependencies = self .doc .entry("tool") .or_insert(implicit()) @@ -98,10 +82,7 @@ impl PyProjectTomlMut { .entry("uv") .or_insert(Item::Table(Table::new())) .as_table_mut() - .ok_or(Error::MalformedSources)?; - - // Get or create the `tool.uv.dev-dependencies` array. - let dev_dependencies = tool_uv + .ok_or(Error::MalformedSources)? .entry("dev-dependencies") .or_insert(Item::Value(Value::Array(Array::new()))) .as_array_mut() @@ -111,82 +92,215 @@ impl PyProjectTomlMut { add_dependency(req, dev_dependencies, source.is_some())?; if let Some(source) = source { - // Get or create `tool.uv.sources`. - let sources = tool_uv - .entry("sources") - .or_insert(Item::Table(Table::new())) - .as_table_mut() - .ok_or(Error::MalformedSources)?; - - add_source(&name, &source, sources)?; + self.add_source(&name, &source)?; } Ok(()) } + /// Adds a dependency to `project.optional-dependencies`. + pub fn add_optional_dependency( + &mut self, + req: Requirement, + group: &ExtraName, + source: Option, + ) -> Result<(), Error> { + // Get or create `project.optional-dependencies`. + let optional_dependencies = self + .doc + .entry("project") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .ok_or(Error::MalformedDependencies)? + .entry("optional-dependencies") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .ok_or(Error::MalformedDependencies)?; + + let group = optional_dependencies + .entry(group.as_ref()) + .or_insert(Item::Value(Value::Array(Array::new()))) + .as_array_mut() + .ok_or(Error::MalformedDependencies)?; + + let name = req.name.clone(); + add_dependency(req, group, source.is_some())?; + + if let Some(source) = source { + self.add_source(&name, &source)?; + } + + 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`. + let sources = self + .doc + .entry("tool") + .or_insert(implicit()) + .as_table_mut() + .ok_or(Error::MalformedSources)? + .entry("uv") + .or_insert(implicit()) + .as_table_mut() + .ok_or(Error::MalformedSources)? + .entry("sources") + .or_insert(Item::Table(Table::new())) + .as_table_mut() + .ok_or(Error::MalformedSources)?; + + add_source(name, source, sources)?; + Ok(()) + } + /// Removes all occurrences of dependencies with the given name. pub fn remove_dependency(&mut self, req: &PackageName) -> Result, Error> { // Try to get `project.dependencies`. let Some(dependencies) = self .doc .get_mut("project") - .and_then(Item::as_table_mut) + .map(|project| project.as_table_mut().ok_or(Error::MalformedSources)) + .transpose()? .and_then(|project| project.get_mut("dependencies")) + .map(|dependencies| dependencies.as_array_mut().ok_or(Error::MalformedSources)) + .transpose()? else { return Ok(Vec::new()); }; - let dependencies = dependencies - .as_array_mut() - .ok_or(Error::MalformedDependencies)?; let requirements = remove_dependency(req, dependencies); - - // Remove a matching source from `tool.uv.sources`, if it exists. - if let Some(sources) = self - .doc - .get_mut("tool") - .and_then(Item::as_table_mut) - .and_then(|tool| tool.get_mut("uv")) - .and_then(Item::as_table_mut) - .and_then(|tool_uv| tool_uv.get_mut("sources")) - { - let sources = sources.as_table_mut().ok_or(Error::MalformedSources)?; - sources.remove(req.as_ref()); - } + self.remove_source(req)?; Ok(requirements) } /// 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 + // Try to get `tool.uv.dev-dependencies`. + let Some(dev_dependencies) = self .doc .get_mut("tool") - .and_then(Item::as_table_mut) + .map(|tool| tool.as_table_mut().ok_or(Error::MalformedSources)) + .transpose()? .and_then(|tool| tool.get_mut("uv")) - .and_then(Item::as_table_mut) + .map(|tool_uv| tool_uv.as_table_mut().ok_or(Error::MalformedSources)) + .transpose()? + .and_then(|tool_uv| tool_uv.get_mut("dev-dependencies")) + .map(|dependencies| dependencies.as_array_mut().ok_or(Error::MalformedSources)) + .transpose()? else { return Ok(Vec::new()); }; - // Try to get `tool.uv.dev-dependencies`. - let Some(dev_dependencies) = tool_uv.get_mut("dev-dependencies") else { - return Ok(Vec::new()); - }; - let dev_dependencies = dev_dependencies - .as_array_mut() - .ok_or(Error::MalformedDependencies)?; - let requirements = remove_dependency(req, dev_dependencies); + self.remove_source(req)?; + + Ok(requirements) + } - // Remove a matching source from `tool.uv.sources`, if it exists. - if let Some(sources) = tool_uv.get_mut("sources") { - let sources = sources.as_table_mut().ok_or(Error::MalformedSources)?; - sources.remove(req.as_ref()); + /// Removes all occurrences of optional dependencies in the group with the given name. + pub fn remove_optional_dependency( + &mut self, + req: &PackageName, + group: &ExtraName, + ) -> Result, Error> { + // Try to get `project.optional-dependencies.`. + let Some(optional_dependencies) = self + .doc + .get_mut("project") + .map(|project| project.as_table_mut().ok_or(Error::MalformedSources)) + .transpose()? + .and_then(|project| project.get_mut("optional-dependencies")) + .map(|extras| extras.as_table_mut().ok_or(Error::MalformedSources)) + .transpose()? + .and_then(|extras| extras.get_mut(group.as_ref())) + .map(|dependencies| dependencies.as_array_mut().ok_or(Error::MalformedSources)) + .transpose()? + else { + return Ok(Vec::new()); }; + let requirements = remove_dependency(req, optional_dependencies); + self.remove_source(req)?; + 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 + .doc + .get_mut("tool") + .map(|tool| tool.as_table_mut().ok_or(Error::MalformedSources)) + .transpose()? + .and_then(|tool| tool.get_mut("uv")) + .map(|tool_uv| tool_uv.as_table_mut().ok_or(Error::MalformedSources)) + .transpose()? + .and_then(|tool_uv| tool_uv.get_mut("sources")) + .map(|sources| sources.as_table_mut().ok_or(Error::MalformedSources)) + .transpose()? + { + sources.remove(name.as_ref()); + } + + Ok(()) + } + + /// Returns all the places in this `pyproject.toml` that contain a dependency with the given + /// name. + /// + /// This method searches `project.dependencies`, `tool.uv.dev-dependencies`, and + /// `tool.uv.optional-dependencies`. + pub fn find_dependency(&self, name: &PackageName) -> Vec { + let mut types = Vec::new(); + + if let Some(project) = self.doc.get("project").and_then(Item::as_table) { + // Check `project.dependencies`. + if let Some(dependencies) = project.get("dependencies").and_then(Item::as_array) { + if !find_dependencies(name, dependencies).is_empty() { + types.push(DependencyType::Production); + } + } + + // Check `project.optional-dependencies`. + if let Some(extras) = project + .get("optional-dependencies") + .and_then(Item::as_table) + { + for (extra, dependencies) in extras { + let Some(dependencies) = dependencies.as_array() else { + continue; + }; + let Ok(extra) = ExtraName::new(extra.to_string()) else { + continue; + }; + + if !find_dependencies(name, dependencies).is_empty() { + types.push(DependencyType::Optional(extra)); + } + } + } + } + + // Check `tool.uv.dev-dependencies`. + if let Some(dev_dependencies) = self + .doc + .get("tool") + .and_then(Item::as_table) + .and_then(|tool| tool.get("uv")) + .and_then(Item::as_table) + .and_then(|tool| tool.get("dev-dependencies")) + .and_then(Item::as_array) + { + if !find_dependencies(name, dev_dependencies).is_empty() { + types.push(DependencyType::Dev); + } + } + + types + } } /// Returns an implicit table. diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index c2b792fff340..40d9eb67909d 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -5,7 +5,7 @@ use uv_cache::Cache; use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClientBuilder}; use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode, SetupPyStrategy}; use uv_dispatch::BuildDispatch; -use uv_distribution::pyproject::{Source, SourceError}; +use uv_distribution::pyproject::{DependencyType, Source, SourceError}; use uv_distribution::pyproject_mut::PyProjectTomlMut; use uv_distribution::{DistributionDatabase, ProjectWorkspace, Workspace}; use uv_git::GitResolver; @@ -27,8 +27,8 @@ use crate::settings::ResolverInstallerSettings; #[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)] pub(crate) async fn add( requirements: Vec, - dev: bool, editable: Option, + dependency_type: DependencyType, raw_sources: bool, rev: Option, tag: Option, @@ -186,10 +186,16 @@ pub(crate) async fn add( (req, source) }; - if dev { - pyproject.add_dev_dependency(req, source)?; - } else { - pyproject.add_dependency(req, source)?; + match dependency_type { + DependencyType::Production => { + pyproject.add_dependency(req, source)?; + } + DependencyType::Dev => { + pyproject.add_dev_dependency(req, source)?; + } + DependencyType::Optional(ref group) => { + pyproject.add_optional_dependency(req, group, source)?; + } } } diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index 03beeee8e9ce..b3bb0967b1a1 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -4,6 +4,7 @@ use pep508_rs::PackageName; use uv_cache::Cache; use uv_client::Connectivity; use uv_configuration::{Concurrency, ExtrasSpecification, PreviewMode}; +use uv_distribution::pyproject::DependencyType; use uv_distribution::pyproject_mut::PyProjectTomlMut; use uv_distribution::{ProjectWorkspace, Workspace}; use uv_toolchain::{ToolchainPreference, ToolchainRequest}; @@ -18,7 +19,7 @@ use crate::settings::{InstallerSettings, ResolverSettings}; #[allow(clippy::too_many_arguments)] pub(crate) async fn remove( requirements: Vec, - dev: bool, + dependency_type: DependencyType, package: Option, python: Option, toolchain_preference: ToolchainPreference, @@ -45,41 +46,33 @@ pub(crate) async fn remove( let mut pyproject = PyProjectTomlMut::from_toml(project.current_project().pyproject_toml())?; for req in requirements { - 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() - { - warn_user!("`{req}` is not a development dependency; try calling `uv remove` without the `--dev` flag"); + match dependency_type { + DependencyType::Production => { + let deps = pyproject.remove_dependency(&req)?; + if deps.is_empty() { + warn_dependency_types(&req, &pyproject); + anyhow::bail!("The dependency `{req}` could not be found in `dependencies`"); } - - 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() - { - warn_user!("`{req}` is a development dependency; try calling `uv remove --dev`"); + DependencyType::Dev => { + let deps = pyproject.remove_dev_dependency(&req)?; + if deps.is_empty() { + warn_dependency_types(&req, &pyproject); + anyhow::bail!( + "The dependency `{req}` could not be found in `dev-dependencies`" + ); + } + } + DependencyType::Optional(ref group) => { + let deps = pyproject.remove_optional_dependency(&req, group)?; + if deps.is_empty() { + warn_dependency_types(&req, &pyproject); + anyhow::bail!( + "The dependency `{req}` could not be found in `optional-dependencies`" + ); + } } - - anyhow::bail!("The dependency `{req}` could not be found in `dependencies`"); } - - continue; } // Save the modified `pyproject.toml`. @@ -143,3 +136,22 @@ pub(crate) async fn remove( Ok(ExitStatus::Success) } + +/// Emit a warning if a dependency with the given name is present as any dependency type. +fn warn_dependency_types(name: &PackageName, pyproject: &PyProjectTomlMut) { + for dep_ty in pyproject.find_dependency(name) { + match dep_ty { + DependencyType::Production => { + warn_user!("`{name}` is a production dependency"); + } + DependencyType::Dev => { + warn_user!("`{name}` is a development dependency; try calling `uv remove --dev`"); + } + DependencyType::Optional(group) => { + warn_user!( + "`{name}` is an optional dependency; try calling `uv remove --optional {group}`" + ); + } + } + } +} diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index feb33268ad7a..53bc73dc0693 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -721,8 +721,8 @@ async fn run() -> Result { commands::add( args.requirements, - args.dev, args.editable, + args.dependency_type, args.raw_sources, args.rev, args.tag, @@ -751,7 +751,7 @@ async fn run() -> Result { commands::remove( args.requirements, - args.dev, + args.dependency_type, args.package, args.python, globals.toolchain_preference, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 5d175d709f2e..106aeae513de 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -22,6 +22,7 @@ use uv_configuration::{ KeyringProviderType, NoBinary, NoBuild, PreviewMode, Reinstall, SetupPyStrategy, TargetTriple, Upgrade, }; +use uv_distribution::pyproject::DependencyType; use uv_normalize::PackageName; use uv_requirements::RequirementsSource; use uv_resolver::{AnnotationStyle, DependencyMode, ExcludeNewer, PreReleaseMode, ResolutionMode}; @@ -434,7 +435,7 @@ impl LockSettings { #[derive(Debug, Clone)] pub(crate) struct AddSettings { pub(crate) requirements: Vec, - pub(crate) dev: bool, + pub(crate) dependency_type: DependencyType, pub(crate) editable: Option, pub(crate) extras: Vec, pub(crate) raw_sources: bool, @@ -454,6 +455,7 @@ impl AddSettings { let AddArgs { requirements, dev, + optional, editable, extra, raw_sources, @@ -472,9 +474,17 @@ impl AddSettings { .map(RequirementsSource::Package) .collect::>(); + let dependency_type = if let Some(group) = optional { + DependencyType::Optional(group) + } else if dev { + DependencyType::Dev + } else { + DependencyType::Production + }; + Self { requirements, - dev, + dependency_type, editable, raw_sources, rev, @@ -497,7 +507,7 @@ impl AddSettings { #[derive(Debug, Clone)] pub(crate) struct RemoveSettings { pub(crate) requirements: Vec, - pub(crate) dev: bool, + pub(crate) dependency_type: DependencyType, pub(crate) package: Option, pub(crate) python: Option, } @@ -508,14 +518,23 @@ impl RemoveSettings { pub(crate) fn resolve(args: RemoveArgs, _filesystem: Option) -> Self { let RemoveArgs { dev, + optional, requirements, package, python, } = args; + let dependency_type = if let Some(group) = optional { + DependencyType::Optional(group) + } else if dev { + DependencyType::Dev + } else { + DependencyType::Production + }; + Self { requirements, - dev, + dependency_type, package, python, } diff --git a/crates/uv/tests/edit.rs b/crates/uv/tests/edit.rs index 8801ed739881..37b4eaea86ac 100644 --- a/crates/uv/tests/edit.rs +++ b/crates/uv/tests/edit.rs @@ -718,6 +718,163 @@ fn add_remove_dev() -> Result<()> { Ok(()) } +/// Add and remove an optional dependency. +#[test] +fn add_remove_optional() -> 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"]).arg("--optional=io"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv add` is experimental and may change without warning. + 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 = 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 = [] + + [project.optional-dependencies] + io = [ + "anyio==3.7.0", + ] + "### + ); + }); + + // `uv add` implies a full lock and sync, including development 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+." + "### + ); + }); + + // 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] + "###); + + // This should fail without --optional. + uv_snapshot!(context.filters(), context.remove(&["anyio"]), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv remove` is experimental and may change without warning. + warning: `anyio` is an optional dependency; try calling `uv remove --optional io` + error: The dependency `anyio` could not be found in `dependencies` + "###); + + // Remove the dependency. + uv_snapshot!(context.filters(), context.remove(&["anyio"]).arg("--optional=io"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv remove` is experimental and may change without warning. + Resolved 1 package in [TIME] + Prepared 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 = [] + + [project.optional-dependencies] + io = [] + "### + ); + }); + + 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+." + "### + ); + }); + + // 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(()) +} + /// Add and remove a workspace dependency. #[test] fn add_remove_workspace() -> Result<()> {