diff --git a/crates/uv-resolver/src/resolution/display.rs b/crates/uv-resolver/src/resolution/display.rs index dd9fc5b4d..e2e6a72a4 100644 --- a/crates/uv-resolver/src/resolution/display.rs +++ b/crates/uv-resolver/src/resolution/display.rs @@ -1,14 +1,11 @@ use std::borrow::Cow; use std::collections::BTreeSet; -use itertools::Itertools; use owo_colors::OwoColorize; use petgraph::visit::EdgeRef; use petgraph::Direction; -use distribution_types::{ - DistributionMetadata, IndexUrl, LocalEditable, Name, SourceAnnotations, Verbatim, -}; +use distribution_types::{IndexUrl, LocalEditable, Name, SourceAnnotations, Verbatim}; use pypi_types::HashDigest; use uv_normalize::PackageName; @@ -155,19 +152,7 @@ impl std::fmt::Display for DisplayResolutionGraph<'_> { let mut line = match node { Node::Editable(editable) => format!("-e {}", editable.verbatim()), Node::Distribution(dist) => { - if self.include_extras && !dist.extras.is_empty() { - let mut extras = dist.extras.clone(); - extras.sort_unstable(); - extras.dedup(); - format!( - "{}[{}]{}", - dist.name(), - extras.into_iter().join(", "), - dist.version_or_url().verbatim() - ) - } else { - dist.verbatim().to_string() - } + dist.to_requirements_txt(self.include_extras).to_string() } }; diff --git a/crates/uv-resolver/src/resolution/mod.rs b/crates/uv-resolver/src/resolution/mod.rs index e2553d69c..ebefefceb 100644 --- a/crates/uv-resolver/src/resolution/mod.rs +++ b/crates/uv-resolver/src/resolution/mod.rs @@ -1,6 +1,11 @@ +use std::borrow::Cow; use std::fmt::Display; +use std::path::Path; -use distribution_types::{DistributionMetadata, Name, ResolvedDist, VersionOrUrlRef}; +use itertools::Itertools; + +use distribution_types::{DistributionMetadata, Name, ResolvedDist, Verbatim, VersionOrUrlRef}; +use pep508_rs::{split_scheme, Scheme}; use pypi_types::{HashDigest, Metadata23}; use uv_normalize::{ExtraName, PackageName}; @@ -13,7 +18,7 @@ mod graph; /// A pinned package with its resolved distribution and metadata. The [`ResolvedDist`] refers to a /// specific distribution (e.g., a specific wheel), while the [`Metadata23`] refers to the metadata /// for the package-version pair. -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct AnnotatedDist { pub(crate) dist: ResolvedDist, pub(crate) extras: Vec, @@ -21,6 +26,74 @@ pub(crate) struct AnnotatedDist { pub(crate) metadata: Metadata23, } +impl AnnotatedDist { + /// Convert the [`AnnotatedDist`] to a requirement that adheres to the `requirements.txt` + /// format. + /// + /// This typically results in a PEP 508 representation of the requirement, but will write an + /// unnamed requirement for relative paths, which can't be represented with PEP 508 (but are + /// supported in `requirements.txt`). + pub(crate) fn to_requirements_txt(&self, include_extras: bool) -> Cow { + // If the URL is not _definitively_ an absolute `file://` URL, write it as a relative path. + if let VersionOrUrlRef::Url(url) = self.dist.version_or_url() { + let given = url.verbatim(); + match split_scheme(&given) { + Some((scheme, path)) => { + match Scheme::parse(scheme) { + Some(Scheme::File) => { + if path + .strip_prefix("//localhost") + .filter(|path| path.starts_with('/')) + .is_some() + { + // Always absolute; nothing to do. + } else if let Some(path) = path.strip_prefix("//") { + // Strip the prefix, to convert, e.g., `file://flask-3.0.3-py3-none-any.whl` to `flask-3.0.3-py3-none-any.whl`. + // + // However, we should allow any of the following: + // - `file://flask-3.0.3-py3-none-any.whl` + // - `file://C:\Users\user\flask-3.0.3-py3-none-any.whl` + // - `file:///C:\Users\user\flask-3.0.3-py3-none-any.whl` + if !path.starts_with("${PROJECT_ROOT}") + && !Path::new(path).has_root() + { + return Cow::Owned(path.to_string()); + } + } else { + // Ex) `file:./flask-3.0.3-py3-none-any.whl` + return given; + } + } + Some(_) => {} + None => { + // Ex) `flask @ C:\Users\user\flask-3.0.3-py3-none-any.whl` + return given; + } + } + } + None => { + // Ex) `flask @ flask-3.0.3-py3-none-any.whl` + return given; + } + } + } + + if self.extras.is_empty() || !include_extras { + self.dist.verbatim() + } else { + let mut extras = self.extras.clone(); + extras.sort_unstable(); + extras.dedup(); + Cow::Owned(format!( + "{}[{}]{}", + self.name(), + extras.into_iter().join(", "), + self.version_or_url().verbatim() + )) + } + } +} + impl Name for AnnotatedDist { fn name(&self) -> &PackageName { self.dist.name() diff --git a/crates/uv/tests/pip_compile.rs b/crates/uv/tests/pip_compile.rs index 744a02d88..f4ceb28d3 100644 --- a/crates/uv/tests/pip_compile.rs +++ b/crates/uv/tests/pip_compile.rs @@ -2065,7 +2065,7 @@ fn allowed_transitive_url_path_dependency() -> Result<()> { ----- stdout ----- # This file was autogenerated by uv via the following command: # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z requirements.in - hatchling-editable @ ${HATCH_PATH} + ${HATCH_PATH} # via -r requirements.in iniconfig @ git+https://github.com/pytest-dev/iniconfig@9cae43103df70bac6fde7b9f35ad11a9f1be0cb4 # via hatchling-editable @@ -2510,7 +2510,7 @@ fn compile_wheel_path_dependency() -> Result<()> { # via flask click==8.1.7 # via flask - flask @ file:flask-3.0.0-py3-none-any.whl + file:flask-3.0.0-py3-none-any.whl # via -r requirements.in itsdangerous==2.1.2 # via flask @@ -2543,7 +2543,7 @@ fn compile_wheel_path_dependency() -> Result<()> { # via flask click==8.1.7 # via flask - flask @ file://flask-3.0.0-py3-none-any.whl + flask-3.0.0-py3-none-any.whl # via -r requirements.in itsdangerous==2.1.2 # via flask @@ -2576,7 +2576,7 @@ fn compile_wheel_path_dependency() -> Result<()> { # via flask click==8.1.7 # via flask - flask @ ./flask-3.0.0-py3-none-any.whl + ./flask-3.0.0-py3-none-any.whl # via -r requirements.in itsdangerous==2.1.2 # via flask @@ -2609,7 +2609,7 @@ fn compile_wheel_path_dependency() -> Result<()> { # via flask click==8.1.7 # via flask - flask @ [TEMP_DIR]/flask-3.0.0-py3-none-any.whl + [TEMP_DIR]/flask-3.0.0-py3-none-any.whl # via -r requirements.in itsdangerous==2.1.2 # via flask @@ -3192,7 +3192,7 @@ fn respect_http_env_var() -> Result<()> { # via flask click==8.1.7 # via flask - flask @ ${URL} + ${URL} # via -r requirements.in itsdangerous==2.1.2 # via flask @@ -3233,7 +3233,7 @@ fn respect_unnamed_env_var() -> Result<()> { # via flask click==8.1.7 # via flask - flask @ ${URL} + ${URL} # via -r requirements.in itsdangerous==2.1.2 # via flask @@ -3305,7 +3305,7 @@ fn respect_file_env_var() -> Result<()> { # via flask click==8.1.7 # via flask - flask @ ${FILE_PATH} + ${FILE_PATH} # via -r requirements.in itsdangerous==2.1.2 # via flask @@ -3468,7 +3468,7 @@ fn recursive_extras_direct_url() -> Result<()> { # via aiohttp attrs==23.2.0 # via aiohttp - black @ ../../scripts/packages/black_editable + ../../scripts/packages/black_editable # via -r [TEMP_DIR]/requirements.in frozenlist==1.4.1 # via @@ -3964,7 +3964,7 @@ fn generate_hashes_local_directory() -> Result<()> { --hash=sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca \ --hash=sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f # via anyio - poetry-editable @ ../../scripts/packages/poetry_editable + ../../scripts/packages/poetry_editable # via -r [TEMP_DIR]/requirements.in sniffio==1.3.1 \ --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ @@ -6799,11 +6799,11 @@ fn compile_root_uri_non_editable() -> Result<()> { ----- stdout ----- # This file was autogenerated by uv via the following command: # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z requirements.in - black @ ${BLACK_PATH} + ${BLACK_PATH} # via # -r requirements.in # root-editable - root-editable @ ${ROOT_PATH} + ${ROOT_PATH} # via -r requirements.in ----- stderr ----- @@ -7186,7 +7186,7 @@ fn unnamed_path_requirement() -> Result<()> { # via # httpx # poetry-editable - black @ ../../scripts/packages/black_editable + ../../scripts/packages/black_editable # via -r [TEMP_DIR]/requirements.in certifi==2024.2.2 # via @@ -7206,13 +7206,13 @@ fn unnamed_path_requirement() -> Result<()> { # anyio # httpx # requests - poetry-editable @ ../../scripts/packages/poetry_editable + ../../scripts/packages/poetry_editable # via -r [TEMP_DIR]/requirements.in requests==2.31.0 # via setup-cfg-editable - setup-cfg-editable @ ../../scripts/packages/setup_cfg_editable + ../../scripts/packages/setup_cfg_editable # via -r [TEMP_DIR]/requirements.in - setup-py-editable @ ../../scripts/packages/setup_py_editable + ../../scripts/packages/setup_py_editable # via -r [TEMP_DIR]/requirements.in sniffio==1.3.1 # via @@ -7322,7 +7322,7 @@ fn dynamic_dependencies() -> Result<()> { # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z [TEMP_DIR]/requirements.in anyio==4.3.0 # via hatchling-dynamic - hatchling-dynamic @ ../../scripts/packages/hatchling_dynamic + ../../scripts/packages/hatchling_dynamic # via -r [TEMP_DIR]/requirements.in idna==3.6 # via anyio @@ -7698,7 +7698,7 @@ requires-python = ">3.8" # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z requirements.in anyio @ file://[TEMP_DIR]/anyio/ # via lib - example @ ./app + ./app # via -r requirements.in idna==3.6 # via anyio @@ -7786,7 +7786,7 @@ requires-python = ">3.8" # via # --override overrides.txt # lib - example @ ./app + ./app # via -r requirements.in idna==3.6 # via anyio @@ -7896,12 +7896,12 @@ requires-python = ">3.8" ----- stdout ----- # This file was autogenerated by uv via the following command: # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z requirements.in --override overrides.txt --constraint constraints.txt - anyio @ ./anyio + ./anyio # via # -c constraints.txt # --override overrides.txt # lib - example @ ./app + ./app # via -r requirements.in idna==3.6 # via anyio @@ -7947,7 +7947,7 @@ requires-python = ">3.8" # uv pip compile --cache-dir [CACHE_DIR] --exclude-newer 2024-03-25T00:00:00Z requirements.in click==8.1.7 # via flask - example @ . + . # via -r requirements.in flask==2.0.0rc1 # via example