From b5a3d099583d14343bc9e88d8eebb8844caa71d9 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 8 Nov 2024 15:13:30 -0500 Subject: [PATCH] Respect comma-separated extras in `--with` (#8946) ## Summary We need to treat `flask,anyio` as two requirements, but `psycopg[binary,pool]` as a single requirement. Closes #8918. --- crates/uv-cli/src/comma.rs | 52 ++++++++++++++++++++++++++++++++ crates/uv-cli/src/comma/tests.rs | 45 +++++++++++++++++++++++++++ crates/uv-cli/src/lib.rs | 29 +++++++++--------- crates/uv/src/settings.rs | 33 +++++++++++++++----- 4 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 crates/uv-cli/src/comma.rs create mode 100644 crates/uv-cli/src/comma/tests.rs diff --git a/crates/uv-cli/src/comma.rs b/crates/uv-cli/src/comma.rs new file mode 100644 index 000000000..ecb8bb8c4 --- /dev/null +++ b/crates/uv-cli/src/comma.rs @@ -0,0 +1,52 @@ +use std::str::FromStr; + +/// A comma-separated string of requirements, e.g., `"flask,anyio"`, that takes extras into account +/// (i.e., treats `"psycopg[binary,pool]"` as a single requirement). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CommaSeparatedRequirements(Vec); + +impl IntoIterator for CommaSeparatedRequirements { + type Item = String; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl FromStr for CommaSeparatedRequirements { + type Err = String; + + fn from_str(input: &str) -> Result { + // Split on commas _outside_ of brackets. + let mut requirements = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + for (i, c) in input.char_indices() { + match c { + '[' => { + depth = depth.saturating_add(1); + } + ']' => { + depth = depth.saturating_sub(1); + } + ',' if depth == 0 => { + let requirement = input[start..i].trim().to_string(); + if !requirement.is_empty() { + requirements.push(requirement); + } + start = i + ','.len_utf8(); + } + _ => {} + } + } + let requirement = input[start..].trim().to_string(); + if !requirement.is_empty() { + requirements.push(requirement); + } + Ok(Self(requirements)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/uv-cli/src/comma/tests.rs b/crates/uv-cli/src/comma/tests.rs new file mode 100644 index 000000000..135d454ea --- /dev/null +++ b/crates/uv-cli/src/comma/tests.rs @@ -0,0 +1,45 @@ +use super::CommaSeparatedRequirements; +use std::str::FromStr; + +#[test] +fn single() { + assert_eq!( + CommaSeparatedRequirements::from_str("flask").unwrap(), + CommaSeparatedRequirements(vec!["flask".to_string()]) + ); +} + +#[test] +fn double() { + assert_eq!( + CommaSeparatedRequirements::from_str("flask,anyio").unwrap(), + CommaSeparatedRequirements(vec!["flask".to_string(), "anyio".to_string()]) + ); +} + +#[test] +fn empty() { + assert_eq!( + CommaSeparatedRequirements::from_str("flask,,anyio").unwrap(), + CommaSeparatedRequirements(vec!["flask".to_string(), "anyio".to_string()]) + ); +} + +#[test] +fn single_extras() { + assert_eq!( + CommaSeparatedRequirements::from_str("psycopg[binary,pool]").unwrap(), + CommaSeparatedRequirements(vec!["psycopg[binary,pool]".to_string()]) + ); +} + +#[test] +fn double_extras() { + assert_eq!( + CommaSeparatedRequirements::from_str("psycopg[binary,pool], flask").unwrap(), + CommaSeparatedRequirements(vec![ + "psycopg[binary,pool]".to_string(), + "flask".to_string() + ]) + ); +} diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ecef77a8e..25a3e8dc3 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -22,6 +22,7 @@ use uv_python::{PythonDownloads, PythonPreference, PythonVersion}; use uv_resolver::{AnnotationStyle, ExcludeNewer, PrereleaseMode, ResolutionMode}; use uv_static::EnvVars; +pub mod comma; pub mod compat; pub mod options; pub mod version; @@ -2674,16 +2675,16 @@ pub struct RunArgs { /// When used in a project, these dependencies will be layered on top of /// the project environment in a separate, ephemeral environment. These /// dependencies are allowed to conflict with those specified by the project. - #[arg(long, value_delimiter = ',')] - pub with: Vec, + #[arg(long)] + pub with: Vec, /// Run with the given packages installed as editables. /// /// When used in a project, these dependencies will be layered on top of /// the project environment in a separate, ephemeral environment. These /// dependencies are allowed to conflict with those specified by the project. - #[arg(long, value_delimiter = ',')] - pub with_editable: Vec, + #[arg(long)] + pub with_editable: Vec, /// Run with all packages listed in the given `requirements.txt` files. /// @@ -3620,16 +3621,16 @@ pub struct ToolRunArgs { pub from: Option, /// Run with the given packages installed. - #[arg(long, value_delimiter = ',')] - pub with: Vec, + #[arg(long)] + pub with: Vec, /// Run with the given packages installed as editables /// /// When used in a project, these dependencies will be layered on top of /// the uv tool's environment in a separate, ephemeral environment. These /// dependencies are allowed to conflict with those specified. - #[arg(long, value_delimiter = ',')] - pub with_editable: Vec, + #[arg(long)] + pub with_editable: Vec, /// Run with all packages listed in the given `requirements.txt` files. #[arg(long, value_delimiter = ',', value_parser = parse_maybe_file_path)] @@ -3681,10 +3682,6 @@ pub struct ToolInstallArgs { #[arg(short, long)] pub editable: bool, - /// Include the given packages as editables. - #[arg(long, value_delimiter = ',')] - pub with_editable: Vec, - /// The package to install commands from. /// /// This option is provided for parity with `uv tool run`, but is redundant with `package`. @@ -3692,8 +3689,12 @@ pub struct ToolInstallArgs { pub from: Option, /// Include the following extra requirements. - #[arg(long, value_delimiter = ',')] - pub with: Vec, + #[arg(long)] + pub with: Vec, + + /// Include the given packages as editables. + #[arg(long)] + pub with_editable: Vec, /// Run all requirements listed in the given `requirements.txt` files. #[arg(long, value_delimiter = ',', value_parser = parse_maybe_file_path)] diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index ee3e8aba8..1ea29d817 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -6,6 +6,7 @@ use std::str::FromStr; use url::Url; use uv_cache::{CacheArgs, Refresh}; +use uv_cli::comma::CommaSeparatedRequirements; use uv_cli::{ options::{flag, resolver_installer_options, resolver_options}, AuthorFrom, BuildArgs, ExportArgs, PublishArgs, PythonDirArgs, ToolUpgradeArgs, @@ -314,8 +315,14 @@ impl RunSettings { dev, no_dev, only_dev, group, no_group, only_group, ), editable: EditableMode::from_args(no_editable), - with, - with_editable, + with: with + .into_iter() + .flat_map(CommaSeparatedRequirements::into_iter) + .collect(), + with_editable: with_editable + .into_iter() + .flat_map(CommaSeparatedRequirements::into_iter) + .collect(), with_requirements: with_requirements .into_iter() .filter_map(Maybe::into_option) @@ -398,8 +405,14 @@ impl ToolRunSettings { Self { command, from, - with, - with_editable, + with: with + .into_iter() + .flat_map(CommaSeparatedRequirements::into_iter) + .collect(), + with_editable: with_editable + .into_iter() + .flat_map(CommaSeparatedRequirements::into_iter) + .collect(), with_requirements: with_requirements .into_iter() .filter_map(Maybe::into_option) @@ -463,8 +476,14 @@ impl ToolInstallSettings { Self { package, from, - with, - with_editable, + with: with + .into_iter() + .flat_map(CommaSeparatedRequirements::into_iter) + .collect(), + with_editable: with_editable + .into_iter() + .flat_map(CommaSeparatedRequirements::into_iter) + .collect(), with_requirements: with_requirements .into_iter() .filter_map(Maybe::into_option) @@ -2635,7 +2654,7 @@ pub(crate) struct PublishSettings { } impl PublishSettings { - /// Resolve the [`crate::settings::PublishSettings`] from the CLI and filesystem configuration. + /// Resolve the [`PublishSettings`] from the CLI and filesystem configuration. pub(crate) fn resolve(args: PublishArgs, filesystem: Option) -> Self { let Options { publish, top_level, ..