feat: Implement --annotation-style parameter for uv pip compile (#1679)

## Summary

Hello there! The motivation for this feature is described here #1678 

## Test Plan

I've added unit tests and also tested this manually on my work project
by comparing it to the original `pip-compile` output - it looks much
like the `pip-compile` generated lock file.
This commit is contained in:
Evgeniy Dubovskoy 2024-02-21 04:08:34 +02:00 committed by GitHub
parent 3cd51ffc92
commit 31752bf4be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 193 additions and 53 deletions

View file

@ -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,

View file

@ -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::<Vec<_>>();
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::<Vec<_>>()
.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::<Vec<_>>()
.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}")?;
}
}

View file

@ -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<PythonVersion>,
exclude_newer: Option<DateTime<Utc>>,
annotation_style: AnnotationStyle,
cache: Cache,
mut printer: Printer,
) -> Result<ExitStatus> {
@ -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)

View file

@ -36,9 +36,6 @@ pub(crate) struct PipCompileCompatArgs {
#[clap(long, hide = true)]
resolver: Option<Resolver>,
#[clap(long, hide = true)]
annotation_style: Option<AnnotationStyle>,
#[clap(long, hide = true)]
max_rounds: Option<usize>,
@ -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.

View file

@ -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<ExitStatus> {
&no_build,
args.python_version,
args.exclude_newer,
args.annotation_style,
cache,
printer,
)

View file

@ -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 {

View file

@ -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<()> {