diff --git a/crates/uv-resolver/src/lib.rs b/crates/uv-resolver/src/lib.rs index 1075b832a5e4..97ed74d87c2e 100644 --- a/crates/uv-resolver/src/lib.rs +++ b/crates/uv-resolver/src/lib.rs @@ -4,7 +4,7 @@ pub use finder::{DistFinder, Reporter as FinderReporter}; pub use manifest::Manifest; pub use options::{Options, OptionsBuilder}; pub use prerelease_mode::PreReleaseMode; -pub use resolution::{Diagnostic, DisplayResolutionGraph, ResolutionGraph}; +pub use resolution::{AnnotationStyle, Diagnostic, DisplayResolutionGraph, ResolutionGraph}; pub use resolution_mode::ResolutionMode; pub use resolver::{ BuildId, DefaultResolverProvider, InMemoryIndex, Reporter as ResolverReporter, Resolver, diff --git a/crates/uv-resolver/src/resolution.rs b/crates/uv-resolver/src/resolution.rs index 8781274dc656..966b1eac2be2 100644 --- a/crates/uv-resolver/src/resolution.rs +++ b/crates/uv-resolver/src/resolution.rs @@ -9,7 +9,6 @@ use petgraph::Direction; use pubgrub::range::Range; use pubgrub::solver::{Kind, State}; use pubgrub::type_aliases::SelectedDependencies; - use rustc_hash::FxHashMap; use url::Url; @@ -23,9 +22,20 @@ use uv_normalize::{ExtraName, PackageName}; use crate::pins::FilePins; use crate::pubgrub::{PubGrubDistribution, PubGrubPackage, PubGrubPriority}; use crate::resolver::VersionsResponse; - use crate::ResolveError; +/// Indicate the style of annotation comments, used to indicate the dependencies that requested each +/// package. +#[derive(Debug, Default, Copy, Clone, PartialEq)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] +pub enum AnnotationStyle { + /// Render the annotations on a single, comma-separated line. + Line, + /// Render each annotation on its own line. + #[default] + Split, +} + /// A complete resolution graph in which every node represents a pinned package and every edge /// represents a dependency between two pinned packages. #[derive(Debug)] @@ -256,11 +266,14 @@ pub struct DisplayResolutionGraph<'a> { /// Whether to include annotations in the output, to indicate which dependency or dependencies /// requested each package. include_annotations: bool, + /// The style of annotation comments, used to indicate the dependencies that requested each + /// package. + annotation_style: AnnotationStyle, } impl<'a> From<&'a ResolutionGraph> for DisplayResolutionGraph<'a> { fn from(resolution: &'a ResolutionGraph) -> Self { - Self::new(resolution, false, true) + Self::new(resolution, false, true, AnnotationStyle::default()) } } @@ -270,11 +283,13 @@ impl<'a> DisplayResolutionGraph<'a> { underlying: &'a ResolutionGraph, show_hashes: bool, include_annotations: bool, + annotation_style: AnnotationStyle, ) -> DisplayResolutionGraph<'a> { Self { resolution: underlying, show_hashes, include_annotations, + annotation_style, } } } @@ -339,16 +354,13 @@ impl std::fmt::Display for DisplayResolutionGraph<'_> { // Print out the dependency graph. for (index, node) in nodes { // Display the node itself. - match node { - Node::Distribution(_, dist) => { - write!(f, "{}", dist.verbatim())?; - } - Node::Editable(_, editable) => { - write!(f, "-e {}", editable.verbatim())?; - } - } + let mut line = match node { + Node::Distribution(_, dist) => format!("{}", dist.verbatim()), + Node::Editable(_, editable) => format!("-e {}", editable.verbatim()), + }; // Display the distribution hashes, if any. + let mut has_hashes = false; if self.show_hashes { if let Some(hashes) = self .resolution @@ -358,13 +370,17 @@ impl std::fmt::Display for DisplayResolutionGraph<'_> { { for hash in hashes { if let Some(hash) = hash.to_string() { - writeln!(f, " \\")?; - write!(f, " --hash={hash}")?; + has_hashes = true; + line.push_str(" \\\n"); + line.push_str(" --hash="); + line.push_str(&hash); } } } } - writeln!(f)?; + + // Determine the annotation comment and separator (between comment and requirement). + let mut annotation = None; if self.include_annotations { // Display all dependencies. @@ -376,20 +392,49 @@ impl std::fmt::Display for DisplayResolutionGraph<'_> { .collect::>(); edges.sort_unstable_by_key(|package| package.name()); - match edges.len() { - 0 => {} - 1 => { - for dependency in edges { - writeln!(f, "{}", format!(" # via {}", dependency.name()).green())?; + match self.annotation_style { + AnnotationStyle::Line => { + if !edges.is_empty() { + let separator = if has_hashes { "\n " } else { " " }; + let deps = edges + .into_iter() + .map(|dependency| dependency.name().to_string()) + .collect::>() + .join(", "); + let comment = format!("# via {deps}").green().to_string(); + annotation = Some((separator, comment)); } } - _ => { - writeln!(f, "{}", " # via".green())?; - for dependency in edges { - writeln!(f, "{}", format!(" # {}", dependency.name()).green())?; + AnnotationStyle::Split => match edges.as_slice() { + [] => {} + [edge] => { + let separator = "\n"; + let comment = format!(" # via {}", edge.name()).green().to_string(); + annotation = Some((separator, comment)); } - } + edges => { + let separator = "\n"; + let deps = edges + .iter() + .map(|dependency| format!(" # {}", dependency.name())) + .collect::>() + .join("\n"); + let comment = format!(" # via\n{deps}").green().to_string(); + annotation = Some((separator, comment)); + } + }, + } + } + + if let Some((separator, comment)) = annotation { + // Assemble the line with the annotations and remove trailing whitespaces. + for line in format!("{line:24}{separator}{comment}").lines() { + let line = line.trim_end(); + writeln!(f, "{line}")?; } + } else { + // Write the line as is. + writeln!(f, "{line}")?; } } diff --git a/crates/uv/src/commands/pip_compile.rs b/crates/uv/src/commands/pip_compile.rs index 9d372a1b8dba..f1732a914da2 100644 --- a/crates/uv/src/commands/pip_compile.rs +++ b/crates/uv/src/commands/pip_compile.rs @@ -28,8 +28,8 @@ use uv_installer::{Downloader, NoBinary}; use uv_interpreter::{Interpreter, PythonVersion}; use uv_normalize::{ExtraName, PackageName}; use uv_resolver::{ - DependencyMode, DisplayResolutionGraph, InMemoryIndex, Manifest, OptionsBuilder, - PreReleaseMode, ResolutionMode, Resolver, + AnnotationStyle, DependencyMode, DisplayResolutionGraph, InMemoryIndex, Manifest, + OptionsBuilder, PreReleaseMode, ResolutionMode, Resolver, }; use uv_traits::{InFlight, NoBuild, SetupPyStrategy}; use uv_warnings::warn_user; @@ -62,6 +62,7 @@ pub(crate) async fn pip_compile( no_build: &NoBuild, python_version: Option, exclude_newer: Option>, + annotation_style: AnnotationStyle, cache: Cache, mut printer: Printer, ) -> Result { @@ -391,7 +392,12 @@ pub(crate) async fn pip_compile( write!( writer, "{}", - DisplayResolutionGraph::new(&resolution, generate_hashes, include_annotations) + DisplayResolutionGraph::new( + &resolution, + generate_hashes, + include_annotations, + annotation_style + ) )?; Ok(ExitStatus::Success) diff --git a/crates/uv/src/compat/mod.rs b/crates/uv/src/compat/mod.rs index e91086be1ac1..b779f7deba04 100644 --- a/crates/uv/src/compat/mod.rs +++ b/crates/uv/src/compat/mod.rs @@ -36,9 +36,6 @@ pub(crate) struct PipCompileCompatArgs { #[clap(long, hide = true)] resolver: Option, - #[clap(long, hide = true)] - annotation_style: Option, - #[clap(long, hide = true)] max_rounds: Option, @@ -144,21 +141,6 @@ impl CompatArgs for PipCompileCompatArgs { } } - if let Some(annotation_style) = self.annotation_style { - match annotation_style { - AnnotationStyle::Split => { - warn_user!( - "pip-compile's `--annotation-style=split` has no effect (uv always emits split annotations)." - ); - } - AnnotationStyle::Line => { - return Err(anyhow!( - "pip-compile's `--annotation-style=line` is unsupported (uv always emits split annotations)." - )); - } - } - } - if self.max_rounds.is_some() { return Err(anyhow!( "pip-compile's `--max-rounds` is unsupported (uv always resolves until convergence)." @@ -346,12 +328,6 @@ enum Resolver { Legacy, } -#[derive(Debug, Copy, Clone, ValueEnum)] -enum AnnotationStyle { - Line, - Split, -} - /// Arguments for `venv` compatibility. /// /// These represent a subset of the `virtualenv` interface that uv supports by default. diff --git a/crates/uv/src/main.rs b/crates/uv/src/main.rs index e6c262cd2ab6..218cdc4213ba 100644 --- a/crates/uv/src/main.rs +++ b/crates/uv/src/main.rs @@ -19,7 +19,7 @@ use uv_client::Connectivity; use uv_installer::{NoBinary, Reinstall}; use uv_interpreter::PythonVersion; use uv_normalize::{ExtraName, PackageName}; -use uv_resolver::{DependencyMode, PreReleaseMode, ResolutionMode}; +use uv_resolver::{AnnotationStyle, DependencyMode, PreReleaseMode, ResolutionMode}; use uv_traits::{NoBuild, PackageNameSpecifier, SetupPyStrategy}; use crate::commands::{extra_name_with_clap_error, ExitStatus, Upgrade}; @@ -354,6 +354,10 @@ struct PipCompileArgs { #[clap(long, hide = true)] emit_find_links: bool, + /// Choose the style of the annotation comments, which indicate the source of each package. + #[clap(long, default_value_t=AnnotationStyle::Split, value_enum)] + annotation_style: AnnotationStyle, + #[command(flatten)] compat_args: compat::PipCompileCompatArgs, } @@ -898,6 +902,7 @@ async fn run() -> Result { &no_build, args.python_version, args.exclude_newer, + args.annotation_style, cache, printer, ) diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index 633ab58adc95..a5e441615c6c 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -266,7 +266,9 @@ pub fn run_and_format<'a>( // The optional leading +/- is for install logs, the optional next line is for lock files let windows_only_deps = [ ("( [+-] )?colorama==\\d+(\\.[\\d+])+\n( # via .*\n)?"), + ("( [+-] )?colorama==\\d+(\\.[\\d+])+\\s+(# via .*\n)?"), ("( [+-] )?tzdata==\\d+(\\.[\\d+])+\n( # via .*\n)?"), + ("( [+-] )?tzdata==\\d+(\\.[\\d+])+\\s+(# via .*\n)?"), ]; let mut removed_packages = 0; for windows_only_dep in windows_only_deps { diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index 5cb52e0b59f2..72f68c39f64b 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -47,6 +47,33 @@ fn compile_requirements_in() -> Result<()> { Ok(()) } +/// Resolve a specific version of Django from a `requirements.in` file with a `--annotation-style=line` flag. +#[test] +fn compile_requirements_in_annotation_line() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("django==5.0b1")?; + + uv_snapshot!(context + .compile() + .arg("--annotation-style=line") + .arg("requirements.in"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z --annotation-style=line requirements.in + asgiref==3.7.2 # via django + django==5.0b1 + sqlparse==0.4.4 # via django + + ----- stderr ----- + Resolved 3 packages in [TIME] + "###); + + Ok(()) +} + /// Resolve a specific version of Django from a `requirements.in` file on stdin /// when passed a path of `-`. #[test] @@ -550,6 +577,38 @@ fn compile_python_312() -> Result<()> { Ok(()) } +/// Resolve a specific version of Black at Python 3.12 with `--annotation-style=line`. +#[test] +fn compile_python_312_annotation_line() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_in = context.temp_dir.child("requirements.in"); + requirements_in.write_str("black==23.10.1")?; + + uv_snapshot!(context.compile() + .arg("--annotation-style=line") + .arg("requirements.in") + .arg("--python-version") + .arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z --annotation-style=line requirements.in --python-version 3.12 + black==23.10.1 + click==8.1.7 # via black + mypy-extensions==1.0.0 # via black + packaging==23.2 # via black + pathspec==0.11.2 # via black + platformdirs==4.0.0 # via black + + ----- stderr ----- + Resolved 6 packages in [TIME] + "### + ); + + Ok(()) +} + /// Resolve a specific version of Black at Python 3.12 without deps. #[test] fn compile_python_312_no_deps() -> Result<()> { @@ -1426,6 +1485,53 @@ optional-dependencies.bar = [ Ok(()) } +#[test] +fn compile_pyproject_toml_all_extras_annotation_line() -> Result<()> { + let context = TestContext::new("3.12"); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#"[build-system] +requires = ["setuptools", "wheel"] + +[project] +name = "project" +dependencies = ["django==5.0b1"] +optional-dependencies.foo = [ + "anyio==4.0.0", +] +optional-dependencies.bar = [ + "httpcore==0.18.0", +] +"#, + )?; + + uv_snapshot!(context.compile() + .arg("--annotation-style=line") + .arg("pyproject.toml") + .arg("--all-extras"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + # This file was autogenerated by uv via the following command: + # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2023-11-18T12:00:00Z --annotation-style=line pyproject.toml --all-extras + anyio==4.0.0 # via httpcore + asgiref==3.7.2 # via django + certifi==2023.11.17 # via httpcore + django==5.0b1 + h11==0.14.0 # via httpcore + httpcore==0.18.0 + idna==3.4 # via anyio + sniffio==1.3.0 # via anyio, httpcore + sqlparse==0.4.4 # via django + + ----- stderr ----- + Resolved 9 packages in [TIME] + "### + ); + + Ok(()) +} + /// Resolve packages from all optional dependency groups in a `pyproject.toml` file. #[test] fn compile_does_not_allow_both_extra_and_all_extras() -> Result<()> {