Write relative paths with unnamed requirement syntax (#3682)

## Summary

This PR falls back to writing an unnamed requirement if it appears to be
a relative URL. pip is way more flexible when providing an unnamed
requirement than when providing a PEP 508 requirement. For example,
_only_ this works:

```
black @ file:///Users/crmarsh/workspace/uv/scripts/packages/black_editable
```

Any other form will fail.

Meanwhile, _all_ of these work:

```
file:///Users/crmarsh/workspace/uv/scripts/packages/black_editable
scripts/packages/black_editable
./scripts/packages/black_editable
file:./scripts/packages/black_editable
file:scripts/packages/black_editable
```

Closes https://github.com/astral-sh/uv/issues/3180.
This commit is contained in:
Charlie Marsh 2024-05-20 21:22:06 -04:00 committed by GitHub
parent 0362918196
commit 49f0e84f3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 99 additions and 41 deletions

View file

@ -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()
}
};

View file

@ -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<ExtraName>,
@ -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<str> {
// 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()